Skip to content

Commit 379cd71

Browse files
sinasogrigi
authored andcommittedNov 28, 2019
OneToOneField (tortoise#239)
* Added OneToOneField + schema generation * describe_model() now reports OneToOne relations * Prefetch concurrently * Enable foreign keys by default on sqlite
1 parent 335f320 commit 379cd71

26 files changed

+701
-15
lines changed
 

‎.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ celerybeat-schedule
8686

8787
# dotenv
8888
.env
89+
.env3
8990

9091
# virtualenv
9192
.venv

‎CHANGELOG.rst

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ Changelog
77
- The ``auto_now_add`` argument of ``DatetimeField`` is handled correctly in the SQLite backend.
88
- ``unique_together`` now creates named constrains, to prevent the DB from auto-assigning a potentially non-unique constraint name.
99
- Filtering by an ``auto_now`` field doesn't replace the filter value with ``now()`` anymore.
10+
- Implemented ``OneToOneField``, one to one relation between two models.
11+
- Prefetching is done asynchronously now, sending all prefetch request at the same time instead of in sequence.
1012

1113
0.15.1
1214
------

‎CONTRIBUTORS.rst

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Contributors
2121
* Adam Wallner ``@wallneradam``
2222
* Zoltán Szeredi ``@zoliszeredi``
2323
* Rebecca Klauser ``@svms1``
24+
* Sina Sohangir ``@sinaso``
2425

2526
Special Thanks
2627
==============

‎docs/fields.rst

+2
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ Relational Fields
7777
.. autoclass:: tortoise.fields.ForeignKeyField
7878
:exclude-members: to_db_value, to_python_value
7979

80+
.. autoclass:: tortoise.fields.OneToOneField
81+
8082
.. autofunction:: tortoise.fields.ManyToManyField
8183

8284
.. autodata:: tortoise.fields.ForeignKeyRelation

‎examples/relations.py

+23
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ def __str__(self):
3434
return self.name
3535

3636

37+
class Address(Model):
38+
city = fields.CharField(max_length=64)
39+
street = fields.CharField(max_length=128)
40+
41+
event: fields.OneToOneRelation[Event] = fields.OneToOneField(
42+
"models.Event", on_delete=fields.CASCADE, related_name="address", pk=True
43+
)
44+
45+
def __str__(self):
46+
return f"Address({self.city}, {self.street})"
47+
48+
3749
class Team(Model):
3850
id = fields.IntField(pk=True)
3951
name = fields.TextField()
@@ -53,6 +65,9 @@ async def run():
5365
await Event(name="Without participants", tournament_id=tournament.id).save()
5466
event = Event(name="Test", tournament_id=tournament.id)
5567
await event.save()
68+
69+
await Address.create(city="Santa Monica", street="Ocean", event=event)
70+
5671
participants = []
5772
for i in range(2):
5873
team = Team(name=f"Team {(i + 1)}")
@@ -96,6 +111,14 @@ async def run():
96111

97112
print(await Event.filter(id=event.id).values_list("id", "participants__name"))
98113

114+
print(await Address.filter(event=event).first())
115+
116+
event_reload1 = await Event.filter(id=event.id).first()
117+
print(await event_reload1.address)
118+
119+
event_reload2 = await Event.filter(id=event.id).prefetch_related("address").first()
120+
print(event_reload2.address)
121+
99122

100123
if __name__ == "__main__":
101124
run_async(run())

‎tests/model_bad_rel5.py

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""
2+
Testing Models for a bad/wrong relation reference
3+
Wrong reference. App missing.
4+
"""
5+
from tortoise import fields
6+
from tortoise.models import Model
7+
8+
9+
class Tournament(Model):
10+
id = fields.IntField(pk=True)
11+
12+
13+
class Event(Model):
14+
tournament = fields.OneToOneField("Tournament")

‎tests/models_dup3.py

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""
2+
This is the testing Models — Duplicate 3
3+
"""
4+
5+
from tortoise import fields
6+
from tortoise.models import Model
7+
8+
9+
class Tournament(Model):
10+
id = fields.IntField(pk=True)
11+
event = fields.CharField(max_length=32)
12+
13+
14+
class Event(Model):
15+
tournament = fields.OneToOneField("models.Tournament", related_name="event")

‎tests/models_o2o_2.py

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""
2+
This is the testing Models — Bad on_delete parameter
3+
"""
4+
from tortoise import fields
5+
from tortoise.models import Model
6+
7+
8+
class One(Model):
9+
tournament = fields.OneToOneField("models.Two", on_delete="WABOOM")

‎tests/models_o2o_3.py

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""
2+
This is the testing Models — on_delete SET_NULL without null=True
3+
"""
4+
from tortoise import fields
5+
from tortoise.models import Model
6+
7+
8+
class One(Model):
9+
tournament = fields.OneToOneField("models.Two", on_delete=fields.SET_NULL)

‎tests/models_schema_create.py

+16
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,22 @@ class Meta:
4747
indexes = [("manager", "key"), ["manager_id", "name"]]
4848

4949

50+
class TeamAddress(Model):
51+
city = fields.CharField(max_length=50, description="City")
52+
country = fields.CharField(max_length=50, description="Country")
53+
street = fields.CharField(max_length=128, description="Street Address")
54+
team = fields.OneToOneField(
55+
"models.Team", related_name="address", on_delete=fields.CASCADE, pk=True
56+
)
57+
58+
59+
class VenueInformation(Model):
60+
name = fields.CharField(max_length=128)
61+
capacity = fields.IntField()
62+
rent = fields.FloatField()
63+
team = fields.OneToOneField("models.Team", on_delete=fields.SET_NULL, null=True)
64+
65+
5066
class SourceFields(Model):
5167
id = fields.IntField(pk=True, source_field="sometable_id")
5268
chars = fields.CharField(max_length=255, source_field="some_chars_table", index=True)

‎tests/test_bad_relation_reference.py

+21
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,24 @@ async def test_more_than_two_dots_in_reference_init(self):
9898
},
9999
}
100100
)
101+
102+
async def test_no_app_in_o2o_reference_init(self):
103+
with self.assertRaisesRegex(
104+
ConfigurationError, 'OneToOneField accepts model name in format "app.Model"'
105+
):
106+
await Tortoise.init(
107+
{
108+
"connections": {
109+
"default": {
110+
"engine": "tortoise.backends.sqlite",
111+
"credentials": {"file_path": ":memory:"},
112+
}
113+
},
114+
"apps": {
115+
"models": {
116+
"models": ["tests.model_bad_rel5"],
117+
"default_connection": "default",
118+
}
119+
},
120+
}
121+
)

‎tests/test_describe_model.py

+164
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,18 @@ async def test_describe_model_straight(self):
101101
"default": None,
102102
"description": "Tree!",
103103
},
104+
{
105+
"db_column": "o2o_id",
106+
"default": None,
107+
"description": "Line",
108+
"field_type": "IntField",
109+
"generated": False,
110+
"indexed": True,
111+
"name": "o2o_id",
112+
"nullable": True,
113+
"python_type": "int",
114+
"unique": True,
115+
},
104116
],
105117
"fk_fields": [
106118
{
@@ -129,6 +141,33 @@ async def test_describe_model_straight(self):
129141
"description": "Tree!",
130142
}
131143
],
144+
"o2o_fields": [
145+
{
146+
"default": None,
147+
"description": "Line",
148+
"field_type": "OneToOneField",
149+
"generated": False,
150+
"indexed": True,
151+
"name": "o2o",
152+
"nullable": True,
153+
"python_type": "models.StraightFields",
154+
"raw_field": "o2o_id",
155+
"unique": True,
156+
}
157+
],
158+
"backward_o2o_fields": [
159+
{
160+
"default": None,
161+
"description": "Line",
162+
"field_type": "BackwardOneToOneRelation",
163+
"generated": False,
164+
"indexed": False,
165+
"name": "o2o_rev",
166+
"nullable": True,
167+
"python_type": "models.StraightFields",
168+
"unique": False,
169+
}
170+
],
132171
"m2m_fields": [
133172
{
134173
"name": "rel_to",
@@ -217,6 +256,18 @@ async def test_describe_model_straight_native(self):
217256
"default": None,
218257
"description": "Tree!",
219258
},
259+
{
260+
"name": "o2o_id",
261+
"field_type": fields.IntField,
262+
"db_column": "o2o_id",
263+
"python_type": int,
264+
"generated": False,
265+
"nullable": True,
266+
"unique": True,
267+
"indexed": True,
268+
"default": None,
269+
"description": "Line",
270+
},
220271
],
221272
"fk_fields": [
222273
{
@@ -245,6 +296,33 @@ async def test_describe_model_straight_native(self):
245296
"description": "Tree!",
246297
}
247298
],
299+
"o2o_fields": [
300+
{
301+
"default": None,
302+
"description": "Line",
303+
"field_type": fields.OneToOneField,
304+
"generated": False,
305+
"indexed": True,
306+
"name": "o2o",
307+
"nullable": True,
308+
"python_type": StraightFields,
309+
"raw_field": "o2o_id",
310+
"unique": True,
311+
},
312+
],
313+
"backward_o2o_fields": [
314+
{
315+
"default": None,
316+
"description": "Line",
317+
"field_type": fields.BackwardOneToOneRelation,
318+
"generated": False,
319+
"indexed": False,
320+
"name": "o2o_rev",
321+
"nullable": True,
322+
"python_type": StraightFields,
323+
"unique": False,
324+
},
325+
],
248326
"m2m_fields": [
249327
{
250328
"name": "rel_to",
@@ -333,6 +411,18 @@ async def test_describe_model_source(self):
333411
"default": None,
334412
"description": "Tree!",
335413
},
414+
{
415+
"name": "o2o_id",
416+
"field_type": "IntField",
417+
"db_column": "o2o_sometable",
418+
"python_type": "int",
419+
"generated": False,
420+
"nullable": True,
421+
"unique": True,
422+
"indexed": True,
423+
"default": None,
424+
"description": "Line",
425+
},
336426
],
337427
"fk_fields": [
338428
{
@@ -361,6 +451,33 @@ async def test_describe_model_source(self):
361451
"description": "Tree!",
362452
}
363453
],
454+
"o2o_fields": [
455+
{
456+
"default": None,
457+
"description": "Line",
458+
"field_type": "OneToOneField",
459+
"generated": False,
460+
"indexed": True,
461+
"name": "o2o",
462+
"nullable": True,
463+
"python_type": "models.SourceFields",
464+
"raw_field": "o2o_id",
465+
"unique": True,
466+
}
467+
],
468+
"backward_o2o_fields": [
469+
{
470+
"default": None,
471+
"description": "Line",
472+
"field_type": "BackwardOneToOneRelation",
473+
"generated": False,
474+
"indexed": False,
475+
"name": "o2o_rev",
476+
"nullable": True,
477+
"python_type": "models.SourceFields",
478+
"unique": False,
479+
}
480+
],
364481
"m2m_fields": [
365482
{
366483
"name": "rel_to",
@@ -449,6 +566,18 @@ async def test_describe_model_source_native(self):
449566
"default": None,
450567
"description": "Tree!",
451568
},
569+
{
570+
"name": "o2o_id",
571+
"field_type": fields.IntField,
572+
"db_column": "o2o_sometable",
573+
"python_type": int,
574+
"generated": False,
575+
"nullable": True,
576+
"unique": True,
577+
"indexed": True,
578+
"default": None,
579+
"description": "Line",
580+
},
452581
],
453582
"fk_fields": [
454583
{
@@ -477,6 +606,33 @@ async def test_describe_model_source_native(self):
477606
"description": "Tree!",
478607
}
479608
],
609+
"o2o_fields": [
610+
{
611+
"default": None,
612+
"description": "Line",
613+
"field_type": fields.OneToOneField,
614+
"generated": False,
615+
"indexed": True,
616+
"name": "o2o",
617+
"nullable": True,
618+
"python_type": SourceFields,
619+
"raw_field": "o2o_id",
620+
"unique": True,
621+
}
622+
],
623+
"backward_o2o_fields": [
624+
{
625+
"default": None,
626+
"description": "Line",
627+
"field_type": fields.BackwardOneToOneRelation,
628+
"generated": False,
629+
"indexed": False,
630+
"name": "o2o_rev",
631+
"nullable": True,
632+
"python_type": SourceFields,
633+
"unique": False,
634+
}
635+
],
480636
"m2m_fields": [
481637
{
482638
"name": "rel_to",
@@ -554,6 +710,8 @@ async def test_describe_model_uuidpk(self):
554710
"description": None,
555711
},
556712
],
713+
"o2o_fields": [],
714+
"backward_o2o_fields": [],
557715
"m2m_fields": [
558716
{
559717
"name": "peers",
@@ -620,6 +778,8 @@ async def test_describe_model_uuidpk_native(self):
620778
"description": None,
621779
},
622780
],
781+
"o2o_fields": [],
782+
"backward_o2o_fields": [],
623783
"m2m_fields": [
624784
{
625785
"name": "peers",
@@ -700,6 +860,8 @@ async def test_describe_model_json(self):
700860
],
701861
"fk_fields": [],
702862
"backward_fk_fields": [],
863+
"o2o_fields": [],
864+
"backward_o2o_fields": [],
703865
"m2m_fields": [],
704866
},
705867
)
@@ -768,6 +930,8 @@ async def test_describe_model_json_native(self):
768930
],
769931
"fk_fields": [],
770932
"backward_fk_fields": [],
933+
"o2o_fields": [],
934+
"backward_o2o_fields": [],
771935
"m2m_fields": [],
772936
},
773937
)

‎tests/test_generate_schema.py

+100
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,18 @@ async def test_fk_bad_null(self):
110110
):
111111
await self.init_for("tests.models_fk_3")
112112

113+
async def test_o2o_bad_on_delete(self):
114+
with self.assertRaisesRegex(
115+
ConfigurationError, "on_delete can only be CASCADE, RESTRICT or SET_NULL"
116+
):
117+
await self.init_for("tests.models_o2o_2")
118+
119+
async def test_o2o_bad_null(self):
120+
with self.assertRaisesRegex(
121+
ConfigurationError, "If on_delete is SET_NULL, then field must have null=True set"
122+
):
123+
await self.init_for("tests.models_o2o_3")
124+
113125
async def test_m2m_bad_model_name(self):
114126
with self.assertRaisesRegex(
115127
ConfigurationError, 'Foreign key accepts model name in format "app.Model"'
@@ -155,6 +167,12 @@ async def test_schema(self):
155167
) /* The TEAMS! */;
156168
CREATE INDEX "idx_team_manager_676134" ON "team" ("manager_id", "key");
157169
CREATE INDEX "idx_team_manager_ef8f69" ON "team" ("manager_id", "name");
170+
CREATE TABLE "teamaddress" (
171+
"city" VARCHAR(50) NOT NULL /* City */,
172+
"country" VARCHAR(50) NOT NULL /* Country */,
173+
"street" VARCHAR(128) NOT NULL /* Street Address */,
174+
"team_id" VARCHAR(50) NOT NULL UNIQUE PRIMARY KEY REFERENCES "team" ("name") ON DELETE CASCADE
175+
);
158176
CREATE TABLE "tournament" (
159177
"tid" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
160178
"name" VARCHAR(100) NOT NULL /* Tournament name */,
@@ -172,6 +190,13 @@ async def test_schema(self):
172190
CONSTRAINT "uid_event_name_c6f89f" UNIQUE ("name", "prize"),
173191
CONSTRAINT "uid_event_tournam_a5b730" UNIQUE ("tournament_id", "key")
174192
) /* This table contains a list of all the events */;
193+
CREATE TABLE "venueinformation" (
194+
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
195+
"name" VARCHAR(128) NOT NULL,
196+
"capacity" INT NOT NULL,
197+
"rent" REAL NOT NULL,
198+
"team_id" VARCHAR(50) UNIQUE REFERENCES "team" ("name") ON DELETE SET NULL
199+
);
175200
CREATE TABLE "sometable_self" (
176201
"backward_sts" INT NOT NULL REFERENCES "sometable" ("sometable_id") ON DELETE CASCADE,
177202
"sts_forward" INT NOT NULL REFERENCES "sometable" ("sometable_id") ON DELETE CASCADE
@@ -219,6 +244,12 @@ async def test_schema_safe(self):
219244
) /* The TEAMS! */;
220245
CREATE INDEX IF NOT EXISTS "idx_team_manager_676134" ON "team" ("manager_id", "key");
221246
CREATE INDEX IF NOT EXISTS "idx_team_manager_ef8f69" ON "team" ("manager_id", "name");
247+
CREATE TABLE IF NOT EXISTS "teamaddress" (
248+
"city" VARCHAR(50) NOT NULL /* City */,
249+
"country" VARCHAR(50) NOT NULL /* Country */,
250+
"street" VARCHAR(128) NOT NULL /* Street Address */,
251+
"team_id" VARCHAR(50) NOT NULL UNIQUE PRIMARY KEY REFERENCES "team" ("name") ON DELETE CASCADE
252+
);
222253
CREATE TABLE IF NOT EXISTS "tournament" (
223254
"tid" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
224255
"name" VARCHAR(100) NOT NULL /* Tournament name */,
@@ -236,6 +267,13 @@ async def test_schema_safe(self):
236267
CONSTRAINT "uid_event_name_c6f89f" UNIQUE ("name", "prize"),
237268
CONSTRAINT "uid_event_tournam_a5b730" UNIQUE ("tournament_id", "key")
238269
) /* This table contains a list of all the events */;
270+
CREATE TABLE IF NOT EXISTS "venueinformation" (
271+
"id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
272+
"name" VARCHAR(128) NOT NULL,
273+
"capacity" INT NOT NULL,
274+
"rent" REAL NOT NULL,
275+
"team_id" VARCHAR(50) UNIQUE REFERENCES "team" ("name") ON DELETE SET NULL
276+
);
239277
CREATE TABLE IF NOT EXISTS "sometable_self" (
240278
"backward_sts" INT NOT NULL REFERENCES "sometable" ("sometable_id") ON DELETE CASCADE,
241279
"sts_forward" INT NOT NULL REFERENCES "sometable" ("sometable_id") ON DELETE CASCADE
@@ -349,6 +387,13 @@ async def test_schema(self):
349387
KEY `idx_team_manager_676134` (`manager_id`, `key`),
350388
KEY `idx_team_manager_ef8f69` (`manager_id`, `name`)
351389
) CHARACTER SET utf8mb4 COMMENT='The TEAMS!';
390+
CREATE TABLE `teamaddress` (
391+
`city` VARCHAR(50) NOT NULL COMMENT 'City',
392+
`country` VARCHAR(50) NOT NULL COMMENT 'Country',
393+
`street` VARCHAR(128) NOT NULL COMMENT 'Street Address',
394+
`team_id` VARCHAR(50) NOT NULL UNIQUE PRIMARY KEY,
395+
CONSTRAINT `fk_teamaddr_team_1c78d737` FOREIGN KEY (`team_id`) REFERENCES `team` (`name`) ON DELETE CASCADE
396+
) CHARACTER SET utf8mb4;
352397
CREATE TABLE `tournament` (
353398
`tid` SMALLINT NOT NULL PRIMARY KEY AUTO_INCREMENT,
354399
`name` VARCHAR(100) NOT NULL COMMENT 'Tournament name',
@@ -367,6 +412,14 @@ async def test_schema(self):
367412
UNIQUE KEY `uid_event_tournam_a5b730` (`tournament_id`, `key`),
368413
CONSTRAINT `fk_event_tourname_51c2b82d` FOREIGN KEY (`tournament_id`) REFERENCES `tournament` (`tid`) ON DELETE CASCADE
369414
) CHARACTER SET utf8mb4 COMMENT='This table contains a list of all the events';
415+
CREATE TABLE `venueinformation` (
416+
`id` INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
417+
`name` VARCHAR(128) NOT NULL,
418+
`capacity` INT NOT NULL,
419+
`rent` DOUBLE NOT NULL,
420+
`team_id` VARCHAR(50) UNIQUE,
421+
CONSTRAINT `fk_venueinf_team_198af929` FOREIGN KEY (`team_id`) REFERENCES `team` (`name`) ON DELETE SET NULL
422+
) CHARACTER SET utf8mb4;
370423
CREATE TABLE `sometable_self` (
371424
`backward_sts` INT NOT NULL,
372425
`sts_forward` INT NOT NULL,
@@ -423,6 +476,13 @@ async def test_schema_safe(self):
423476
KEY `idx_team_manager_676134` (`manager_id`, `key`),
424477
KEY `idx_team_manager_ef8f69` (`manager_id`, `name`)
425478
) CHARACTER SET utf8mb4 COMMENT='The TEAMS!';
479+
CREATE TABLE IF NOT EXISTS `teamaddress` (
480+
`city` VARCHAR(50) NOT NULL COMMENT 'City',
481+
`country` VARCHAR(50) NOT NULL COMMENT 'Country',
482+
`street` VARCHAR(128) NOT NULL COMMENT 'Street Address',
483+
`team_id` VARCHAR(50) NOT NULL UNIQUE PRIMARY KEY,
484+
CONSTRAINT `fk_teamaddr_team_1c78d737` FOREIGN KEY (`team_id`) REFERENCES `team` (`name`) ON DELETE CASCADE
485+
) CHARACTER SET utf8mb4;
426486
CREATE TABLE IF NOT EXISTS `tournament` (
427487
`tid` SMALLINT NOT NULL PRIMARY KEY AUTO_INCREMENT,
428488
`name` VARCHAR(100) NOT NULL COMMENT 'Tournament name',
@@ -441,6 +501,14 @@ async def test_schema_safe(self):
441501
UNIQUE KEY `uid_event_tournam_a5b730` (`tournament_id`, `key`),
442502
CONSTRAINT `fk_event_tourname_51c2b82d` FOREIGN KEY (`tournament_id`) REFERENCES `tournament` (`tid`) ON DELETE CASCADE
443503
) CHARACTER SET utf8mb4 COMMENT='This table contains a list of all the events';
504+
CREATE TABLE IF NOT EXISTS `venueinformation` (
505+
`id` INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
506+
`name` VARCHAR(128) NOT NULL,
507+
`capacity` INT NOT NULL,
508+
`rent` DOUBLE NOT NULL,
509+
`team_id` VARCHAR(50) UNIQUE,
510+
CONSTRAINT `fk_venueinf_team_198af929` FOREIGN KEY (`team_id`) REFERENCES `team` (`name`) ON DELETE SET NULL
511+
) CHARACTER SET utf8mb4;
444512
CREATE TABLE IF NOT EXISTS `sometable_self` (
445513
`backward_sts` INT NOT NULL,
446514
`sts_forward` INT NOT NULL,
@@ -541,6 +609,15 @@ async def test_schema(self):
541609
CREATE INDEX "idx_team_manager_ef8f69" ON "team" ("manager_id", "name");
542610
COMMENT ON COLUMN "team"."name" IS 'The TEAM name (and PK)';
543611
COMMENT ON TABLE "team" IS 'The TEAMS!';
612+
CREATE TABLE "teamaddress" (
613+
"city" VARCHAR(50) NOT NULL,
614+
"country" VARCHAR(50) NOT NULL,
615+
"street" VARCHAR(128) NOT NULL,
616+
"team_id" VARCHAR(50) NOT NULL UNIQUE PRIMARY KEY REFERENCES "team" ("name") ON DELETE CASCADE
617+
);
618+
COMMENT ON COLUMN "teamaddress"."city" IS 'City';
619+
COMMENT ON COLUMN "teamaddress"."country" IS 'Country';
620+
COMMENT ON COLUMN "teamaddress"."street" IS 'Street Address';
544621
CREATE TABLE "tournament" (
545622
"tid" SMALLSERIAL NOT NULL PRIMARY KEY,
546623
"name" VARCHAR(100) NOT NULL,
@@ -565,6 +642,13 @@ async def test_schema(self):
565642
COMMENT ON COLUMN "event"."token" IS 'Unique token';
566643
COMMENT ON COLUMN "event"."tournament_id" IS 'FK to tournament';
567644
COMMENT ON TABLE "event" IS 'This table contains a list of all the events';
645+
CREATE TABLE "venueinformation" (
646+
"id" SERIAL NOT NULL PRIMARY KEY,
647+
"name" VARCHAR(128) NOT NULL,
648+
"capacity" INT NOT NULL,
649+
"rent" DOUBLE PRECISION NOT NULL,
650+
"team_id" VARCHAR(50) UNIQUE REFERENCES "team" ("name") ON DELETE SET NULL
651+
);
568652
CREATE TABLE "sometable_self" (
569653
"backward_sts" INT NOT NULL REFERENCES "sometable" ("sometable_id") ON DELETE CASCADE,
570654
"sts_forward" INT NOT NULL REFERENCES "sometable" ("sometable_id") ON DELETE CASCADE
@@ -615,6 +699,15 @@ async def test_schema_safe(self):
615699
CREATE INDEX IF NOT EXISTS "idx_team_manager_ef8f69" ON "team" ("manager_id", "name");
616700
COMMENT ON COLUMN "team"."name" IS 'The TEAM name (and PK)';
617701
COMMENT ON TABLE "team" IS 'The TEAMS!';
702+
CREATE TABLE IF NOT EXISTS "teamaddress" (
703+
"city" VARCHAR(50) NOT NULL,
704+
"country" VARCHAR(50) NOT NULL,
705+
"street" VARCHAR(128) NOT NULL,
706+
"team_id" VARCHAR(50) NOT NULL UNIQUE PRIMARY KEY REFERENCES "team" ("name") ON DELETE CASCADE
707+
);
708+
COMMENT ON COLUMN "teamaddress"."city" IS 'City';
709+
COMMENT ON COLUMN "teamaddress"."country" IS 'Country';
710+
COMMENT ON COLUMN "teamaddress"."street" IS 'Street Address';
618711
CREATE TABLE IF NOT EXISTS "tournament" (
619712
"tid" SMALLSERIAL NOT NULL PRIMARY KEY,
620713
"name" VARCHAR(100) NOT NULL,
@@ -639,6 +732,13 @@ async def test_schema_safe(self):
639732
COMMENT ON COLUMN "event"."token" IS 'Unique token';
640733
COMMENT ON COLUMN "event"."tournament_id" IS 'FK to tournament';
641734
COMMENT ON TABLE "event" IS 'This table contains a list of all the events';
735+
CREATE TABLE IF NOT EXISTS "venueinformation" (
736+
"id" SERIAL NOT NULL PRIMARY KEY,
737+
"name" VARCHAR(128) NOT NULL,
738+
"capacity" INT NOT NULL,
739+
"rent" DOUBLE PRECISION NOT NULL,
740+
"team_id" VARCHAR(50) UNIQUE REFERENCES "team" ("name") ON DELETE SET NULL
741+
);
642742
CREATE TABLE IF NOT EXISTS "sometable_self" (
643743
"backward_sts" INT NOT NULL REFERENCES "sometable" ("sometable_id") ON DELETE CASCADE,
644744
"sts_forward" INT NOT NULL REFERENCES "sometable" ("sometable_id") ON DELETE CASCADE

‎tests/test_init.py

+18
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,24 @@ async def test_dup2_init(self):
7272
}
7373
)
7474

75+
async def test_dup3_init(self):
76+
with self.assertRaisesRegex(
77+
ConfigurationError, 'backward relation "event" duplicates in model Tournament'
78+
):
79+
await Tortoise.init(
80+
{
81+
"connections": {
82+
"default": {
83+
"engine": "tortoise.backends.sqlite",
84+
"credentials": {"file_path": ":memory:"},
85+
}
86+
},
87+
"apps": {
88+
"models": {"models": ["tests.models_dup3"], "default_connection": "default"}
89+
},
90+
}
91+
)
92+
7593
async def test_generated_nonint(self):
7694
with self.assertRaisesRegex(
7795
ConfigurationError, "Generated primary key allowed only for IntField and BigIntField"

‎tests/test_model_methods.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from tests.testmodels import Event, NoID, Team, Tournament
1+
from tests.testmodels import Address, Event, NoID, Team, Tournament
22
from tortoise.contrib import test
33
from tortoise.exceptions import (
44
ConfigurationError,
@@ -153,6 +153,15 @@ def test_rev_m2m(self):
153153
):
154154
Team(name="a", events=[])
155155

156+
async def test_rev_o2o(self):
157+
with self.assertRaisesRegex(
158+
ConfigurationError,
159+
"You can't set backward one to one relations through init, "
160+
"change related model instead",
161+
):
162+
address = await Address.create(city="Santa Monica", street="Ocean")
163+
await Event(name="a", address=address)
164+
156165
def test_fk_unsaved(self):
157166
with self.assertRaisesRegex(OperationalError, "You should first call .save()"):
158167
Event(name="a", tournament=Tournament(name="a"))

‎tests/test_prefetching.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from tests.testmodels import Event, Team, Tournament
1+
from tests.testmodels import Address, Event, Team, Tournament
22
from tortoise.contrib import test
33
from tortoise.exceptions import FieldError, OperationalError
44
from tortoise.functions import Count
@@ -52,6 +52,15 @@ async def test_prefetch_m2m(self):
5252
)
5353
self.assertEqual(len(fetched_events.participants), 1)
5454

55+
async def test_prefetch_o2o(self):
56+
tournament = await Tournament.create(name="tournament")
57+
event = await Event.create(name="First", tournament=tournament)
58+
await Address.create(city="Santa Monica", street="Ocean", event=event)
59+
60+
fetched_events = await Event.all().prefetch_related("address").first()
61+
62+
self.assertEqual(fetched_events.address.city, "Santa Monica")
63+
5564
async def test_prefetch_nested(self):
5665
tournament = await Tournament.create(name="tournament")
5766
event = await Event.create(name="First", tournament=tournament)

‎tests/test_relations.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from tests.testmodels import Employee, Event, Team, Tournament
1+
from tests.testmodels import Address, Employee, Event, Team, Tournament
22
from tortoise.contrib import test
33
from tortoise.exceptions import FieldError, NoValuesFetched
44
from tortoise.functions import Count
@@ -135,6 +135,14 @@ async def test_m2m_remove(self):
135135
fetched_event = await Event.first().prefetch_related("participants")
136136
self.assertEqual(len(fetched_event.participants), 1)
137137

138+
async def test_o2o_lazy(self):
139+
tournament = await Tournament.create(name="tournament")
140+
event = await Event.create(name="First", tournament=tournament)
141+
await Address.create(city="Santa Monica", street="Ocean", event=event)
142+
143+
fetched_address = await event.address
144+
self.assertEqual(fetched_address.city, "Santa Monica")
145+
138146
async def test_m2m_remove_two(self):
139147
tournament = await Tournament.create(name="tournament")
140148
event = await Event.create(name="First", tournament=tournament)

‎tests/testmodels.py

+23
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,15 @@ def __str__(self):
6262
return self.name
6363

6464

65+
class Address(Model):
66+
city = fields.CharField(max_length=64)
67+
street = fields.CharField(max_length=128)
68+
69+
event: fields.OneToOneRelation[Event] = fields.OneToOneField(
70+
"models.Event", on_delete=fields.CASCADE, related_name="address", null=True
71+
)
72+
73+
6574
class Team(Model):
6675
id = fields.IntField(pk=True)
6776
name = fields.TextField()
@@ -446,6 +455,11 @@ class StraightFields(Model):
446455
)
447456
fkrev: fields.ReverseRelation["StraightFields"]
448457

458+
o2o: fields.OneToOneNullableRelation["StraightFields"] = fields.OneToOneField(
459+
"models.StraightFields", related_name="o2o_rev", null=True, description="Line"
460+
)
461+
o2o_rev: fields.Field
462+
449463
rel_to: fields.ManyToManyRelation["StraightFields"] = fields.ManyToManyField(
450464
"models.StraightFields", related_name="rel_from", description="M2M to myself"
451465
)
@@ -472,6 +486,15 @@ class SourceFields(Model):
472486
)
473487
fkrev: fields.ReverseRelation["SourceFields"]
474488

489+
o2o: fields.OneToOneNullableRelation["SourceFields"] = fields.OneToOneField(
490+
"models.SourceFields",
491+
related_name="o2o_rev",
492+
null=True,
493+
source_field="o2o_sometable",
494+
description="Line",
495+
)
496+
o2o_rev: fields.Field
497+
475498
rel_to: fields.ManyToManyRelation["SourceFields"] = fields.ManyToManyField(
476499
"models.SourceFields",
477500
related_name="rel_from",

‎tortoise/__init__.py

+69-5
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ def describe_model(cls, model: Type[Model], serializable: bool = True) -> dict:
7474
"data_fields": [...] # Data fields
7575
"fk_fields": [...] # Foreign Key fields FROM this model
7676
"backward_fk_fields": [...] # Foreign Key fields TO this model
77+
"o2o_fields": [...] # OneToOne fields FROM this model
78+
"backward_o2o_fields": [...] # OneToOne fields TO this model
7779
"m2m_fields": [...] # Many-to-Many fields
7880
}
7981
@@ -160,14 +162,21 @@ def describe_field(name: str) -> dict:
160162
}
161163

162164
# Foreign Keys have
163-
if isinstance(field, fields.ForeignKeyField):
165+
if isinstance(field, (fields.ForeignKeyField, fields.OneToOneField)):
164166
del desc["db_column"]
165167
desc["raw_field"] = field.source_field
166168
else:
167169
del desc["raw_field"]
168170

169171
# These fields are entierly "virtual", so no direct DB representation
170-
if isinstance(field, (fields.ManyToManyFieldInstance, fields.BackwardFKRelation)):
172+
if isinstance(
173+
field,
174+
(
175+
fields.ManyToManyFieldInstance,
176+
fields.BackwardFKRelation,
177+
fields.BackwardOneToOneRelation,
178+
),
179+
):
171180
del desc["db_column"]
172181

173182
return desc
@@ -196,6 +205,16 @@ def describe_field(name: str) -> dict:
196205
for name in model._meta.fields_map.keys()
197206
if name in model._meta.backward_fk_fields
198207
],
208+
"o2o_fields": [
209+
describe_field(name)
210+
for name in model._meta.fields_map.keys()
211+
if name in model._meta.o2o_fields
212+
],
213+
"backward_o2o_fields": [
214+
describe_field(name)
215+
for name in model._meta.fields_map.keys()
216+
if name in model._meta.backward_o2o_fields
217+
],
199218
"m2m_fields": [
200219
describe_field(name)
201220
for name in model._meta.fields_map.keys()
@@ -283,6 +302,8 @@ def split_reference(reference: str) -> Tuple[str, str]:
283302
if not model._meta.table:
284303
model._meta.table = model.__name__.lower()
285304

305+
pk_attr_changed = False
306+
286307
for field in model._meta.fk_fields:
287308
fk_object = cast(fields.ForeignKeyField, model._meta.fields_map[field])
288309
reference = fk_object.model_name
@@ -320,6 +341,48 @@ def split_reference(reference: str) -> Tuple[str, str]:
320341
)
321342
related_model._meta.add_field(backward_relation_name, fk_relation)
322343

344+
for field in model._meta.o2o_fields:
345+
o2o_object = cast(fields.OneToOneField, model._meta.fields_map[field])
346+
reference = o2o_object.model_name
347+
related_app_name, related_model_name = split_reference(reference)
348+
related_model = get_related_model(related_app_name, related_model_name)
349+
350+
key_field = f"{field}_id"
351+
key_o2o_object = deepcopy(related_model._meta.pk)
352+
key_o2o_object.pk = o2o_object.pk
353+
key_o2o_object.index = o2o_object.index
354+
key_o2o_object.default = o2o_object.default
355+
key_o2o_object.null = o2o_object.null
356+
key_o2o_object.unique = o2o_object.unique
357+
key_o2o_object.generated = o2o_object.generated
358+
key_o2o_object.reference = o2o_object
359+
key_o2o_object.description = o2o_object.description
360+
if o2o_object.source_field:
361+
key_o2o_object.source_field = o2o_object.source_field
362+
o2o_object.source_field = key_field
363+
else:
364+
o2o_object.source_field = key_field
365+
key_o2o_object.source_field = key_field
366+
model._meta.add_field(key_field, key_o2o_object)
367+
368+
o2o_object.field_type = related_model
369+
backward_relation_name = o2o_object.related_name
370+
if not backward_relation_name:
371+
backward_relation_name = f"{model._meta.table}"
372+
if backward_relation_name in related_model._meta.fields:
373+
raise ConfigurationError(
374+
f'backward relation "{backward_relation_name}" duplicates in'
375+
f" model {related_model_name}"
376+
)
377+
o2o_relation = fields.BackwardOneToOneRelation(
378+
model, f"{field}_id", null=True, description=o2o_object.description
379+
)
380+
related_model._meta.add_field(backward_relation_name, o2o_relation)
381+
382+
if o2o_object.pk:
383+
pk_attr_changed = True
384+
model._meta.pk_attr = key_field
385+
323386
for field in list(model._meta.m2m_fields):
324387
m2m_object = cast(fields.ManyToManyFieldInstance, model._meta.fields_map[field])
325388
if m2m_object._generated:
@@ -340,9 +403,7 @@ def split_reference(reference: str) -> Tuple[str, str]:
340403

341404
backward_relation_name = m2m_object.related_name
342405
if not backward_relation_name:
343-
backward_relation_name = (
344-
m2m_object.related_name
345-
) = f"{model._meta.table}_through"
406+
backward_relation_name = m2m_object.related_name = f"{model._meta.table}s"
346407
if backward_relation_name in related_model._meta.fields:
347408
raise ConfigurationError(
348409
f'backward relation "{backward_relation_name}" duplicates in'
@@ -371,6 +432,9 @@ def split_reference(reference: str) -> Tuple[str, str]:
371432
model._meta.filters.update(get_m2m_filters(field, m2m_object))
372433
related_model._meta.add_field(backward_relation_name, m2m_relation)
373434

435+
if pk_attr_changed:
436+
model._meta.finalise_pk()
437+
374438
@classmethod
375439
def _discover_client_class(cls, engine: str) -> BaseDBAsyncClient:
376440
# Let exception bubble up for transparency

‎tortoise/backends/base/executor.py

+34-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import datetime
23
import decimal
34
from copy import copy
@@ -203,6 +204,29 @@ async def _prefetch_reverse_relation(
203204
relation_container._set_result_for_query(related_object_map.get(instance.pk, []))
204205
return instance_list
205206

207+
async def _prefetch_reverse_o2o_relation(
208+
self, instance_list: list, field: str, related_query
209+
) -> list:
210+
instance_id_set: set = {
211+
self._field_to_db(instance._meta.pk, instance.pk, instance)
212+
for instance in instance_list
213+
}
214+
relation_field = self.model._meta.fields_map[field].relation_field # type: ignore
215+
216+
related_object_list = await related_query.filter(
217+
**{f"{relation_field}__in": list(instance_id_set)}
218+
)
219+
220+
related_object_map: dict = {}
221+
for entry in related_object_list:
222+
object_id = getattr(entry, relation_field)
223+
related_object_map[object_id] = entry
224+
225+
for instance in instance_list:
226+
setattr(instance, f"_{field}", related_object_map.get(instance.pk, None))
227+
228+
return instance_list
229+
206230
async def _prefetch_m2m_relation(self, instance_list: list, field: str, related_query) -> list:
207231
instance_id_set: set = {
208232
self._field_to_db(instance._meta.pk, instance.pk, instance)
@@ -318,15 +342,23 @@ def _make_prefetch_queries(self) -> None:
318342
async def _do_prefetch(self, instance_id_list: list, field: str, related_query) -> list:
319343
if field in self.model._meta.backward_fk_fields:
320344
return await self._prefetch_reverse_relation(instance_id_list, field, related_query)
345+
346+
if field in self.model._meta.backward_o2o_fields:
347+
return await self._prefetch_reverse_o2o_relation(instance_id_list, field, related_query)
348+
321349
if field in self.model._meta.m2m_fields:
322350
return await self._prefetch_m2m_relation(instance_id_list, field, related_query)
323351
return await self._prefetch_direct_relation(instance_id_list, field, related_query)
324352

325353
async def _execute_prefetch_queries(self, instance_list: list) -> list:
326354
if instance_list and (self.prefetch_map or self._prefetch_queries):
327355
self._make_prefetch_queries()
328-
for field, related_query in self._prefetch_queries.items():
329-
await self._do_prefetch(instance_list, field, related_query)
356+
prefetch_tasks = [
357+
self._do_prefetch(instance_list, field, related_query)
358+
for field, related_query in self._prefetch_queries.items()
359+
]
360+
await asyncio.gather(*prefetch_tasks)
361+
330362
return instance_list
331363

332364
async def fetch_for_list(self, instance_list: list, *args) -> list:

‎tortoise/backends/base/schema_generator.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from tortoise import fields
66
from tortoise.exceptions import ConfigurationError
7+
from tortoise.fields import OneToOneField
78
from tortoise.utils import get_escape_translation_table
89

910
# pylint: disable=R0201
@@ -187,11 +188,12 @@ def _get_table_sql(self, model, safe=True) -> dict:
187188
else ""
188189
)
189190
# TODO: PK generation needs to move out of schema generator.
190-
if field_object.pk:
191+
if field_object.pk and not isinstance(field_object.reference, OneToOneField):
191192
pk_string = self._get_primary_key_create_string(field_object, db_field, comment)
192193
if pk_string:
193194
fields_to_create.append(pk_string)
194195
continue
196+
195197
nullable = "NOT NULL" if not field_object.null else ""
196198
unique = "UNIQUE" if field_object.unique else ""
197199

‎tortoise/backends/sqlite/client.py

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def __init__(self, file_path: str, **kwargs) -> None:
4444
self.pragmas = kwargs.copy()
4545
self.pragmas.pop("connection_name", None)
4646
self.pragmas.pop("fetch_inserted", None)
47+
self.pragmas["foreign_keys"] = "ON"
4748

4849
self._connection: Optional[aiosqlite.Connection] = None
4950
self._lock = asyncio.Lock()

‎tortoise/fields.py

+76
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,78 @@ def __await__(self): # pragma: nocoverage
478478
... # pylint: disable=W0104
479479

480480

481+
OneToOneNullableRelation = Union[Awaitable[Optional[MODEL]], Optional[MODEL]]
482+
"""
483+
Type hint for the result of accessing the :class:`.OneToOneField` field in the model
484+
when obtained model can be nullable.
485+
"""
486+
487+
OneToOneRelation = Union[Awaitable[MODEL], MODEL]
488+
"""
489+
Type hint for the result of accessing the :class:`.OneToOneField` field in the model.
490+
"""
491+
492+
493+
class OneToOneField(Field):
494+
"""
495+
OneToOne relation field.
496+
497+
This field represents a one to one relation to another model.
498+
499+
You must provide the following:
500+
501+
``model_name``:
502+
The name of the related model in a :samp:`'{app}.{model}'` format.
503+
504+
The following is optional:
505+
506+
``related_name``:
507+
The attribute name on the related model to reverse resolve the one to one relation.
508+
``on_delete``:
509+
One of:
510+
``field.CASCADE``:
511+
Indicate that the model should be cascade deleted if related model gets deleted.
512+
``field.RESTRICT``:
513+
Indicate that the related model delete will be restricted as long as a
514+
one to one relation points to it.
515+
``field.SET_NULL``:
516+
Resets the field to NULL in case the related model gets deleted.
517+
Can only be set if field has ``null=True`` set.
518+
``field.SET_DEFAULT``:
519+
Resets the field to ``default`` value in case the related model gets deleted.
520+
Can only be set is field has a ``default`` set.
521+
"""
522+
523+
__slots__ = (
524+
"field_type",
525+
# type will be set later, so we need a slot to be able to write it
526+
"model_name",
527+
"related_name",
528+
"on_delete",
529+
)
530+
has_db_field = False
531+
532+
def __init__(
533+
self, model_name: str, related_name: Optional[str] = None, on_delete=CASCADE, **kwargs
534+
) -> None:
535+
kwargs["unique"] = True
536+
super().__init__(**kwargs)
537+
# self.field_type: "Type[Model]" = None # type: ignore
538+
if len(model_name.split(".")) != 2:
539+
raise ConfigurationError('OneToOneField accepts model name in format "app.Model"')
540+
self.model_name = model_name
541+
self.related_name = related_name
542+
if on_delete not in {CASCADE, RESTRICT, SET_NULL}:
543+
raise ConfigurationError("on_delete can only be CASCADE, RESTRICT or SET_NULL")
544+
if on_delete == SET_NULL and not bool(kwargs.get("null")):
545+
raise ConfigurationError("If on_delete is SET_NULL, then field must have null=True set")
546+
self.on_delete = on_delete
547+
548+
# we need this for IDEs so that they don't say that the field is not awaitable
549+
def __await__(self):
550+
... # pylint: disable=W0104
551+
552+
481553
class ManyToManyFieldInstance(Field):
482554
__slots__ = (
483555
"field_type", # Here we need type to be able to set dyamically
@@ -565,6 +637,10 @@ def __init__(
565637
self.description = description
566638

567639

640+
class BackwardOneToOneRelation(BackwardFKRelation):
641+
pass
642+
643+
568644
class ReverseRelation(Generic[MODEL]):
569645
"""
570646
Relation container for :class:`.ForeignKeyField`.

‎tortoise/models.py

+69-2
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,15 @@ def _rfk_getter(self, _key, ftype, frelfield):
4747
return val
4848

4949

50+
def _ro2o_getter(self, _key, ftype, frelfield):
51+
if hasattr(self, _key):
52+
return getattr(self, _key)
53+
54+
val = ftype.filter(**{frelfield: self.pk}).first()
55+
setattr(self, _key, val)
56+
return val
57+
58+
5059
def _m2m_getter(self, _key, field_object):
5160
val = getattr(self, _key, None)
5261
if val is None:
@@ -63,6 +72,8 @@ class MetaInfo:
6372
"fields",
6473
"db_fields",
6574
"m2m_fields",
75+
"o2o_fields",
76+
"backward_o2o_fields",
6677
"fk_fields",
6778
"backward_fk_fields",
6879
"fetch_fields",
@@ -99,7 +110,9 @@ def __init__(self, meta) -> None:
99110
self.db_fields: Set[str] = set()
100111
self.m2m_fields: Set[str] = set()
101112
self.fk_fields: Set[str] = set()
113+
self.o2o_fields: Set[str] = set()
102114
self.backward_fk_fields: Set[str] = set()
115+
self.backward_o2o_fields: Set[str] = set()
103116
self.fetch_fields: Set[str] = set()
104117
self.fields_db_projection: Dict[str, str] = {}
105118
self.fields_db_projection_reverse: Dict[str, str] = {}
@@ -132,6 +145,8 @@ def add_field(self, name: str, value: Field):
132145

133146
if isinstance(value, fields.ManyToManyFieldInstance):
134147
self.m2m_fields.add(name)
148+
elif isinstance(value, fields.BackwardOneToOneRelation):
149+
self.backward_o2o_fields.add(name)
135150
elif isinstance(value, fields.BackwardFKRelation):
136151
self.backward_fk_fields.add(name)
137152

@@ -170,7 +185,13 @@ def finalise_fields(self) -> None:
170185
self.fields_db_projection_reverse = {
171186
value: key for key, value in self.fields_db_projection.items()
172187
}
173-
self.fetch_fields = self.m2m_fields | self.backward_fk_fields | self.fk_fields
188+
self.fetch_fields = (
189+
self.m2m_fields
190+
| self.backward_fk_fields
191+
| self.fk_fields
192+
| self.backward_o2o_fields
193+
| self.o2o_fields
194+
)
174195

175196
generated_fields = []
176197
for field in self.fields_map.values():
@@ -216,6 +237,42 @@ def _generate_lazy_fk_m2m_fields(self) -> None:
216237
),
217238
)
218239

240+
# Create lazy one to one fields on model.
241+
for key in self.o2o_fields:
242+
_key = f"_{key}"
243+
relation_field = self.fields_map[key].source_field
244+
setattr(
245+
self._model,
246+
key,
247+
property(
248+
partial(
249+
_fk_getter,
250+
_key=_key,
251+
ftype=self.fields_map[key].field_type,
252+
relation_field=relation_field,
253+
),
254+
partial(_fk_setter, _key=_key, relation_field=relation_field),
255+
partial(_fk_setter, value=None, _key=_key, relation_field=relation_field),
256+
),
257+
)
258+
259+
# Create lazy reverse one to one fields on model.
260+
for key in self.backward_o2o_fields:
261+
_key = f"_{key}"
262+
field_object: fields.BackwardOneToOneRelation = self.fields_map[key] # type: ignore
263+
setattr(
264+
self._model,
265+
key,
266+
property(
267+
partial(
268+
_ro2o_getter,
269+
_key=_key,
270+
ftype=field_object.field_type,
271+
frelfield=field_object.relation_field,
272+
),
273+
),
274+
)
275+
219276
# Create lazy M2M fields on model.
220277
for key in self.m2m_fields:
221278
_key = f"_{key}"
@@ -268,6 +325,7 @@ def __new__(mcs, name: str, bases, attrs: dict, *args, **kwargs):
268325
filters: Dict[str, Dict[str, dict]] = {}
269326
fk_fields: Set[str] = set()
270327
m2m_fields: Set[str] = set()
328+
o2o_fields: Set[str] = set()
271329
meta_class = attrs.get("Meta", type("Meta", (), {}))
272330
pk_attr: str = "id"
273331

@@ -350,6 +408,8 @@ def __search_for_field_attributes(base, attrs: dict):
350408

351409
if isinstance(value, fields.ForeignKeyField):
352410
fk_fields.add(key)
411+
elif isinstance(value, fields.OneToOneField):
412+
o2o_fields.add(key)
353413
elif isinstance(value, fields.ManyToManyFieldInstance):
354414
m2m_fields.add(key)
355415
else:
@@ -380,6 +440,8 @@ def __search_for_field_attributes(base, attrs: dict):
380440
meta._filters = filters
381441
meta.fk_fields = fk_fields
382442
meta.backward_fk_fields = set()
443+
meta.o2o_fields = o2o_fields
444+
meta.backward_o2o_fields = set()
383445
meta.m2m_fields = m2m_fields
384446
meta.default_connection = None
385447
meta.pk_attr = pk_attr
@@ -409,7 +471,7 @@ def __init__(self, **kwargs) -> None:
409471
passed_fields = {*kwargs.keys()} | meta.fetch_fields
410472

411473
for key, value in kwargs.items():
412-
if key in meta.fk_fields:
474+
if key in meta.fk_fields or key in meta.o2o_fields:
413475
if value and not value._saved_in_db:
414476
raise OperationalError(
415477
f"You should first call .save() on {value} before referring to it"
@@ -427,6 +489,11 @@ def __init__(self, **kwargs) -> None:
427489
raise ConfigurationError(
428490
"You can't set backward relations through init, change related model instead"
429491
)
492+
elif key in meta.backward_o2o_fields:
493+
raise ConfigurationError(
494+
"You can't set backward one to one relations through init,"
495+
" change related model instead"
496+
)
430497
elif key in meta.m2m_fields:
431498
raise ConfigurationError(
432499
"You can't set m2m relations through init, use m2m_manager instead"

‎tortoise/query_utils.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ def _resolve_regular_kwarg(self, model, key, value) -> QueryModifier:
250250
return modifier
251251

252252
def _get_actual_filter_params(self, model, key, value) -> Tuple[str, Any]:
253-
if key in model._meta.fk_fields:
253+
if key in model._meta.fk_fields or key in model._meta.o2o_fields:
254254
field_object = model._meta.fields_map[key]
255255
if hasattr(value, "pk"):
256256
filter_value = value.pk

‎tortoise/queryset.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -562,7 +562,7 @@ def _make_query(self) -> None:
562562
raise FieldError(f"Unknown keyword argument {key} for model {self.model}")
563563
if field_object.pk:
564564
raise IntegrityError(f"Field {key} is PK and can not be updated")
565-
if isinstance(field_object, fields.ForeignKeyField):
565+
if isinstance(field_object, (fields.ForeignKeyField, fields.OneToOneField)):
566566
fk_field: str = field_object.source_field # type: ignore
567567
db_field = self.model._meta.fields_map[fk_field].source_field
568568
value = executor.column_map[fk_field](value.pk, None)

0 commit comments

Comments
 (0)
Please sign in to comment.