Skip to content

Commit 12ce850

Browse files
committed
Add a way to export SQLAlchemy models from Gel (EdgeDB).
Create separate intermediate objects to represent links with link properties (because ORMs tend to view them as such). Don't allow crazy names (not usual type of identifiers). Multi properties behave more like plain multi links than anything else (because they have a separate table with the property value in it), so they should be reflected like that establishing a relationship. Reflect Gel modules into Python modules. 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 f8f4893 commit 12ce850

14 files changed

+1837
-1
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

gel/_testbase.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
import gel
3636
from gel import asyncio_client
3737
from gel import blocking_client
38+
from gel.orm.introspection import get_schema_json
39+
from gel.orm.sqla import ModelGenerator
3840

3941

4042
log = logging.getLogger(__name__)
@@ -627,6 +629,46 @@ def adapt_call(cls, result):
627629
return result
628630

629631

632+
class SQLATestCase(SyncQueryTestCase):
633+
SQLAPACKAGE = None
634+
DEFAULT_MODULE = 'default'
635+
636+
@classmethod
637+
def setUpClass(cls):
638+
super().setUpClass()
639+
640+
class_set_up = os.environ.get('EDGEDB_TEST_CASES_SET_UP')
641+
if not class_set_up:
642+
# Now that the DB is setup, generate the SQLAlchemy models from it
643+
spec = get_schema_json(cls.client)
644+
# We'll need a temp directory to setup the generated Python
645+
# package
646+
cls.tmpsqladir = tempfile.TemporaryDirectory()
647+
gen = ModelGenerator(
648+
outdir=os.path.join(cls.tmpsqladir.name, cls.SQLAPACKAGE),
649+
basemodule=cls.SQLAPACKAGE,
650+
)
651+
gen.render_models(spec)
652+
sys.path.append(cls.tmpsqladir.name)
653+
654+
@classmethod
655+
def tearDownClass(cls):
656+
super().tearDownClass()
657+
# cleanup the temp modules
658+
sys.path.remove(cls.tmpsqladir.name)
659+
cls.tmpsqladir.cleanup()
660+
661+
@classmethod
662+
def get_dsn_for_sqla(cls):
663+
cargs = cls.get_connect_args(database=cls.get_database_name())
664+
dsn = (
665+
f'postgresql://{cargs["user"]}:{cargs["password"]}'
666+
f'@{cargs["host"]}:{cargs["port"]}/{cargs["database"]}'
667+
)
668+
669+
return dsn
670+
671+
630672
_lock_cnt = 0
631673

632674

gel/orm/__init__.py

Whitespace-only changes.

gel/orm/introspection.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import json
2+
import re
3+
4+
5+
INTRO_QUERY = '''
6+
with module schema
7+
select ObjectType {
8+
name,
9+
links: {
10+
name,
11+
readonly,
12+
required,
13+
cardinality,
14+
exclusive := exists (
15+
select .constraints
16+
filter .name = 'std::exclusive'
17+
),
18+
target: {name},
19+
20+
properties: {
21+
name,
22+
readonly,
23+
required,
24+
cardinality,
25+
exclusive := exists (
26+
select .constraints
27+
filter .name = 'std::exclusive'
28+
),
29+
target: {name},
30+
},
31+
} filter .name != '__type__',
32+
properties: {
33+
name,
34+
readonly,
35+
required,
36+
cardinality,
37+
exclusive := exists (
38+
select .constraints
39+
filter .name = 'std::exclusive'
40+
),
41+
target: {name},
42+
},
43+
backlinks := <array<str>>[],
44+
}
45+
filter
46+
not .builtin
47+
and
48+
not .internal
49+
and
50+
not re_test('^(std|cfg|sys|schema)::', .name);
51+
'''
52+
53+
MODULE_QUERY = '''
54+
with
55+
module schema,
56+
m := (select `Module` filter not .builtin)
57+
select m.name;
58+
'''
59+
60+
CLEAN_NAME = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
61+
62+
63+
def get_sql_name(name):
64+
# Just remove the module name
65+
name = name.rsplit('::', 1)[-1]
66+
67+
return name
68+
69+
70+
def check_name(name):
71+
# Just remove module separators and check the rest
72+
name = name.replace('::', '')
73+
if not CLEAN_NAME.fullmatch(name):
74+
raise RuntimeError(
75+
f'Non-alphanumeric names are not supported: {name}')
76+
77+
78+
def get_schema_json(client):
79+
types = json.loads(client.query_json(INTRO_QUERY))
80+
modules = json.loads(client.query_json(MODULE_QUERY))
81+
82+
return _process_links(types, modules)
83+
84+
85+
async def async_get_schema_json(client):
86+
types = json.loads(await client.query_json(INTRO_QUERY))
87+
modules = json.loads(client.query_json(MODULE_QUERY))
88+
89+
return _process_links(types, modules)
90+
91+
92+
def _process_links(types, modules):
93+
# Figure out all the backlinks, link tables, and links with link
94+
# properties that require their own intermediate objects.
95+
type_map = {}
96+
link_tables = []
97+
link_objects = []
98+
prop_objects = []
99+
100+
for spec in types:
101+
check_name(spec['name'])
102+
type_map[spec['name']] = spec
103+
104+
for prop in spec['properties']:
105+
check_name(prop['name'])
106+
107+
for spec in types:
108+
mod = spec["name"].rsplit('::', 1)[0]
109+
sql_source = get_sql_name(spec["name"])
110+
111+
for prop in spec['properties']:
112+
exclusive = prop['exclusive']
113+
cardinality = prop['cardinality']
114+
name = prop['name']
115+
check_name(name)
116+
sql_name = get_sql_name(name)
117+
118+
if cardinality == 'Many':
119+
# Multi property will make its own "link table". But since it
120+
# doesn't link to any other object the link table itself must
121+
# be reflected as an object.
122+
pobj = {
123+
'module': mod,
124+
'name': f'{sql_source}_{sql_name}_prop',
125+
'table': f'{sql_source}.{sql_name}',
126+
'links': [{
127+
'name': 'source',
128+
'required': True,
129+
'cardinality': 'One' if exclusive else 'Many',
130+
'exclusive': cardinality == 'One',
131+
'target': {'name': spec['name']},
132+
'has_link_object': False,
133+
}],
134+
'properties': [{
135+
'name': 'target',
136+
'required': True,
137+
'cardinality': 'One',
138+
'exclusive': False,
139+
'target': prop['target'],
140+
'has_link_object': False,
141+
}],
142+
}
143+
prop_objects.append(pobj)
144+
145+
for link in spec['links']:
146+
if link['name'] != '__type__':
147+
target = link['target']['name']
148+
cardinality = link['cardinality']
149+
exclusive = link['exclusive']
150+
name = link['name']
151+
check_name(name)
152+
sql_name = get_sql_name(name)
153+
154+
objtype = type_map[target]
155+
objtype['backlinks'].append({
156+
'name': f'backlink_via_{sql_name}',
157+
# flip cardinality and exclusivity
158+
'cardinality': 'One' if exclusive else 'Many',
159+
'exclusive': cardinality == 'One',
160+
'target': {'name': spec['name']},
161+
'has_link_object': False,
162+
})
163+
164+
for prop in link['properties']:
165+
check_name(prop['name'])
166+
167+
link['has_link_object'] = False
168+
# Any link with properties should become its own intermediate
169+
# object, since ORMs generally don't have a special convenient
170+
# way of exposing this as just a link table.
171+
if len(link['properties']) > 2:
172+
# more than just 'source' and 'target' properties
173+
lobj = {
174+
'module': mod,
175+
'name': f'{sql_source}_{sql_name}_link',
176+
'table': f'{sql_source}.{sql_name}',
177+
'links': [],
178+
'properties': [],
179+
}
180+
for prop in link['properties']:
181+
if prop['name'] in {'source', 'target'}:
182+
lobj['links'].append(prop)
183+
else:
184+
lobj['properties'].append(prop)
185+
186+
link_objects.append(lobj)
187+
link['has_link_object'] = True
188+
objtype['backlinks'][-1]['has_link_object'] = True
189+
190+
elif cardinality == 'Many':
191+
# Add a link table for One-to-Many and Many-to-Many
192+
link_tables.append({
193+
'module': mod,
194+
'name': f'{sql_source}_{sql_name}_table',
195+
'table': f'{sql_source}.{sql_name}',
196+
'source': spec["name"],
197+
'target': target,
198+
})
199+
200+
return {
201+
'modules': modules,
202+
'object_types': types,
203+
'link_tables': link_tables,
204+
'link_objects': link_objects,
205+
'prop_objects': prop_objects,
206+
}

0 commit comments

Comments
 (0)