Skip to content

Commit be2af63

Browse files
committed
Add a way to export SQLAlchemy models from Gel (EdgeDB).
Currently just have a function `render_models` that takes a client in order to print SQLAlchemy models. They are printed to stdout which can be redirected as needed. Add tests that setup a Gel database and then generate the SQLAlchemy models from it. The individual tests use a SQLAlchemy session to access the database using postgres protocol.
1 parent 96161a2 commit be2af63

File tree

8 files changed

+735
-2
lines changed

8 files changed

+735
-2
lines changed

.flake8

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
[flake8]
22
ignore = B008,B023,B306,E203,E402,E731,D100,D101,D102,D103,D104,D105,W503,W504,E252,F999,F541
3-
exclude = .git,__pycache__,build,dist,.eggs
3+
exclude = .git,__pycache__,build,dist,.eggs,generated

edgedb/_testbase.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
import edgedb
3636
from edgedb import asyncio_client
3737
from edgedb import blocking_client
38-
38+
from edgedb.gelalchemy.models import get_schema_json, render_models
3939

4040
log = logging.getLogger(__name__)
4141

@@ -598,6 +598,34 @@ def adapt_call(cls, result):
598598
return result
599599

600600

601+
class SQLATestCase(SyncQueryTestCase):
602+
SQLAMODELS = None
603+
DEFAULT_MODULE = 'default'
604+
605+
@classmethod
606+
def setUpClass(cls):
607+
super().setUpClass()
608+
609+
class_set_up = os.environ.get('EDGEDB_TEST_CASES_SET_UP')
610+
if not class_set_up:
611+
# Now that the DB is setup, generate the SQLAlchemy models from it
612+
spec = get_schema_json(cls.client)
613+
614+
with open(cls.SQLAMODELS, 'w') as f:
615+
with contextlib.redirect_stdout(f):
616+
render_models(spec)
617+
618+
@classmethod
619+
def get_dsn_for_sqla(cls):
620+
cargs = cls.get_connect_args(database=cls.get_database_name())
621+
dsn = (
622+
f'postgresql://{cargs["user"]}:{cargs["password"]}'
623+
f'@{cargs["host"]}:{cargs["port"]}/{cargs["database"]}'
624+
)
625+
626+
return dsn
627+
628+
601629
_lock_cnt = 0
602630

603631

edgedb/gelalchemy/__init__.py

Whitespace-only changes.

edgedb/gelalchemy/models.py

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import edgedb
2+
import json
3+
4+
5+
DSN = 'edgedb://edgedb@localhost:5656/edgedb?tls_security=insecure'
6+
client = edgedb.create_client(DSN)
7+
INDENT = ' ' * 4
8+
9+
GEL_SCALAR_MAP = {
10+
'std::bool': ('bool', 'Boolean'),
11+
'std::str': ('str', 'String'),
12+
'std::int16': ('int', 'Integer'),
13+
'std::int32': ('int', 'Integer'),
14+
'std::int64': ('int', 'Integer'),
15+
'std::float32': ('float', 'Float'),
16+
'std::float64': ('float', 'Float'),
17+
'std::uuid': ('uuid.UUID', 'Uuid'),
18+
}
19+
20+
INTRO_QUERY = '''
21+
with module schema
22+
select ObjectType {
23+
name,
24+
links: {
25+
name,
26+
readonly,
27+
required,
28+
cardinality,
29+
exclusive := exists (
30+
select .constraints
31+
filter .name = 'std::exclusive'
32+
),
33+
target: {name},
34+
35+
properties: {
36+
name,
37+
readonly,
38+
required,
39+
cardinality,
40+
exclusive := exists (
41+
select .constraints
42+
filter .name = 'std::exclusive'
43+
),
44+
target: {name},
45+
} filter .name not in {'source', 'target'},
46+
},
47+
properties: {
48+
name,
49+
readonly,
50+
required,
51+
cardinality,
52+
exclusive := exists (
53+
select .constraints
54+
filter .name = 'std::exclusive'
55+
),
56+
target: {name},
57+
},
58+
backlinks := <array<str>>[],
59+
}
60+
filter
61+
not .builtin
62+
and
63+
not .internal
64+
and
65+
not re_test('^(std|cfg|sys|schema)::', .name);
66+
'''
67+
68+
69+
def get_object_type_name(name):
70+
# for now just assume stuff is in "defualt" module
71+
return name.replace('default::', '')
72+
73+
74+
def get_schema_json(client):
75+
types = json.loads(client.query_json(INTRO_QUERY))
76+
77+
return _process_links(types)
78+
79+
80+
async def async_get_schema_json(client):
81+
types = json.loads(await client.query_json(INTRO_QUERY))
82+
83+
return _process_links(types)
84+
85+
86+
def _process_links(types):
87+
# Figure out all the backlinks and link tables
88+
link_tables = []
89+
type_map = {}
90+
for spec in types:
91+
type_map[get_object_type_name(spec['name'])] = spec
92+
93+
for spec in types:
94+
for link in spec['links']:
95+
if link['name'] != '__type__':
96+
target = get_object_type_name(link['target']['name'])
97+
cardinality = link['cardinality']
98+
exclusive = link['exclusive']
99+
100+
objtype = type_map[target]
101+
objtype['backlinks'].append({
102+
'name': f'backlink_via_{link["name"]}',
103+
'cardinality': 'One' if exclusive else 'Many',
104+
'exclusive': cardinality == 'One',
105+
'target': {'name': spec['name']},
106+
})
107+
108+
# Add a link table for One-to-Many and Many-to-Many
109+
if cardinality == 'Many':
110+
source = get_object_type_name(spec["name"])
111+
link_tables.append({
112+
'name': f'{source}_{link["name"]}_table',
113+
'table': f'{source}.{link["name"]}',
114+
'source': f'{source}.id',
115+
'target': f'{target}.id',
116+
})
117+
118+
return {
119+
'object_types': types,
120+
'link_tables': link_tables,
121+
}
122+
123+
124+
def render_link_table(spec):
125+
print(f'''\
126+
{spec["name"]} = Table(
127+
{spec["table"]!r},
128+
Base.metadata,
129+
Column("source", ForeignKey({spec["source"]!r})),
130+
Column("target", ForeignKey({spec["target"]!r})),
131+
)\
132+
''')
133+
134+
135+
def render_type(spec):
136+
# assume nice names for now
137+
name = get_object_type_name(spec['name'])
138+
139+
print(f'class {name}(Base):')
140+
print(f'{INDENT}__tablename__ = {name!r}')
141+
142+
# Add two fields that all objects have
143+
print(
144+
f'''
145+
id: Mapped[uuid.UUID] = mapped_column(
146+
Uuid(), primary_key=True, server_default='uuid_generate_v4()')
147+
# This is maintained entirely by Gel, the server_default simply indicates
148+
# to SQLAlchemy that this value may be omitted.
149+
gel_type_id: Mapped[uuid.UUID] = mapped_column(
150+
'__type__', Uuid(), unique=True, server_default='PLACEHOLDER')'''
151+
)
152+
153+
if spec['properties']:
154+
print(f'\n # Properties:')
155+
for prop in spec['properties']:
156+
if prop['name'] != 'id':
157+
render_prop(prop)
158+
159+
if spec['links']:
160+
print(f'\n # Links:')
161+
for link in spec['links']:
162+
if link['name'] != '__type__':
163+
render_link(link, name)
164+
165+
if spec['backlinks']:
166+
print(f'\n # Back-links:')
167+
for link in spec['backlinks']:
168+
render_backlink(link)
169+
170+
171+
def render_prop(spec):
172+
name = spec['name']
173+
nullable = not spec['required']
174+
175+
pytype, sqlatype = GEL_SCALAR_MAP[spec['target']['name']]
176+
177+
print(
178+
f'\
179+
{name}: Mapped[{pytype}] = '
180+
f'mapped_column({sqlatype}(), nullable={nullable})'
181+
)
182+
183+
184+
def render_link(spec, parent):
185+
name = spec['name']
186+
nullable = not spec['required']
187+
target = get_object_type_name(spec['target']['name'])
188+
cardinality = spec['cardinality']
189+
bklink = f'backlink_via_{name}'
190+
191+
if cardinality == 'One':
192+
print(
193+
f'{INDENT}{name}_id: Mapped[uuid.UUID] = '
194+
f'mapped_column(Uuid(), ForeignKey("{target}.id"), '
195+
f'nullable={nullable})'
196+
)
197+
print(
198+
f'{INDENT}{name}: Mapped[{target!r}] = '
199+
f'relationship(back_populates={bklink!r})'
200+
)
201+
elif cardinality == 'Many':
202+
secondary = f'{parent}_{name}_table'
203+
print(f'{INDENT}{name}: Mapped[List[{target!r}]] = relationship(')
204+
print(
205+
f'{INDENT * 2}{target!r}, secondary={secondary}, '
206+
f'back_populates={bklink!r},'
207+
)
208+
print(f'{INDENT})')
209+
210+
211+
def render_backlink(spec):
212+
name = spec['name']
213+
target = get_object_type_name(spec['target']['name'])
214+
cardinality = spec['cardinality']
215+
exclusive = spec['exclusive']
216+
bklink = name.replace('backlink_via_', '', 1)
217+
218+
if exclusive:
219+
# This is a backlink from a single link. There is no link table
220+
# involved.
221+
if cardinality == 'One':
222+
print(
223+
f'{INDENT}{name}: Mapped[{target!r}] = '
224+
f'relationship(back_populates={bklink!r})'
225+
)
226+
elif cardinality == 'Many':
227+
print(
228+
f'{INDENT}{name}: Mapped[List[{target!r}]] = '
229+
f'relationship(back_populates={bklink!r})'
230+
)
231+
232+
else:
233+
# This backlink involves a link table, so we still treat it as a
234+
# Many-to-Many.
235+
secondary = f'{target}_{bklink}_table'
236+
print(f'{INDENT}{name}: Mapped[List[{target!r}]] = relationship(')
237+
print(
238+
f'{INDENT * 2}{target!r}, secondary={secondary}, '
239+
f'back_populates={bklink!r},'
240+
)
241+
print(f'{INDENT})')
242+
243+
244+
def render_models(spec):
245+
print(f'''\
246+
#
247+
# Automatically generated from Gel schema.
248+
#
249+
250+
import uuid
251+
252+
from typing import List
253+
from typing import Optional
254+
255+
from sqlalchemy import MetaData, Table, Column, ForeignKey
256+
from sqlalchemy import String, Uuid, Integer, Float, Boolean
257+
from sqlalchemy.orm import (DeclarativeBase, Mapped, mapped_column,
258+
relationship)
259+
260+
261+
class Base(DeclarativeBase):
262+
pass
263+
''')
264+
265+
for rec in spec['link_tables']:
266+
render_link_table(rec)
267+
print('\n')
268+
269+
for rec in spec['object_types']:
270+
render_type(rec)
271+
print('\n')

tests/dbsetup/base.edgeql

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
insert User {name := 'Alice'};
2+
insert User {name := 'Billie'};
3+
insert User {name := 'Cameron'};
4+
insert User {name := 'Dana'};
5+
insert User {name := 'Elsa'};
6+
insert User {name := 'Zoe'};
7+
8+
insert UserGroup {
9+
name := 'red',
10+
users := (select User filter .name != 'Zoe'),
11+
};
12+
insert UserGroup {
13+
name := 'green',
14+
users := (select User filter .name in {'Alice', 'Billie'}),
15+
};
16+
insert UserGroup {
17+
name := 'blue',
18+
};
19+
20+
insert GameSession {
21+
num := 123,
22+
players := (select User filter .name in {'Alice', 'Billie'}),
23+
};
24+
insert GameSession {
25+
num := 456,
26+
players := (select User filter .name in {'Dana'}),
27+
};
28+
29+
insert Post {
30+
author := assert_single((select User filter .name = 'Alice')),
31+
body := 'Hello',
32+
};
33+
insert Post {
34+
author := assert_single((select User filter .name = 'Alice')),
35+
body := "I'm Alice",
36+
};
37+
insert Post {
38+
author := assert_single((select User filter .name = 'Cameron')),
39+
body := "I'm Cameron",
40+
};
41+
insert Post {
42+
author := assert_single((select User filter .name = 'Elsa')),
43+
body := '*magic stuff*',
44+
};

tests/dbsetup/base.esdl

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
abstract type Named {
2+
required name: str;
3+
}
4+
5+
type UserGroup extending Named {
6+
# many-to-many
7+
multi link users: User;
8+
}
9+
10+
type GameSession {
11+
required num: int64;
12+
# one-to-many
13+
multi link players: User {
14+
constraint exclusive;
15+
};
16+
}
17+
18+
type User extending Named;
19+
20+
type Post {
21+
required body: str;
22+
required link author: User;
23+
}

tests/generated/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)