forked from datajoint/datajoint-python
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathuser_tables.py
More file actions
290 lines (230 loc) · 8.36 KB
/
user_tables.py
File metadata and controls
290 lines (230 loc) · 8.36 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
"""
Hosts the table tiers, user tables should be derived from.
"""
import re
from .autopopulate import AutoPopulate
from .errors import DataJointError
from .table import Table
from .utils import from_camel_case
_base_regexp = r"[a-z][a-z0-9]*(_[a-z][a-z0-9]*)*"
# attributes that trigger instantiation of user classes
supported_class_attrs = {
"key_source",
"describe",
"alter",
"heading",
"populate",
"progress",
"primary_key",
"proj",
"aggr",
"join",
"extend",
"to_dicts",
"to_pandas",
"to_polars",
"to_arrow",
"to_arrays",
"keys",
"fetch",
"fetch1",
"head",
"tail",
"descendants",
"ancestors",
"parts",
"parents",
"children",
"insert",
"insert1",
"insert_dataframe",
"update1",
"validate",
"drop",
"drop_quick",
"delete",
"delete_quick",
"staged_insert1",
}
class TableMeta(type):
"""
TableMeta subclasses allow applying some instance methods and properties directly
at class level. For example, this allows Table.to_dicts() instead of Table().to_dicts().
"""
def __getattribute__(cls, name):
# trigger instantiation for supported class attrs
return cls().__getattribute__(name) if name in supported_class_attrs else super().__getattribute__(name)
def __and__(cls, arg):
return cls() & arg
def __xor__(cls, arg):
return cls() ^ arg
def __sub__(cls, arg):
return cls() - arg
def __neg__(cls):
return -cls()
def __mul__(cls, arg):
return cls() * arg
def __matmul__(cls, arg):
return cls() @ arg
def __add__(cls, arg):
return cls() + arg
def __iter__(cls):
return iter(cls())
# Class properties - defined on metaclass to work at class level
@property
def connection(cls):
"""The database connection for this table."""
return cls._connection
@property
def table_name(cls):
"""The table name formatted for MySQL."""
if cls._prefix is None:
raise AttributeError("Class prefix is not defined!")
return cls._prefix + from_camel_case(cls.__name__)
@property
def full_table_name(cls):
"""The fully qualified table name (quoted per backend)."""
if cls.database is None:
return None
return cls._connection.adapter.make_full_table_name(cls.database, cls.table_name)
class UserTable(Table, metaclass=TableMeta):
"""
A subclass of UserTable is a dedicated class interfacing a base table.
UserTable is initialized by the decorator generated by schema().
"""
# set by @schema
_connection = None
_heading = None
_support = None
# set by subclass
tier_regexp = None
_prefix = None
@property
def definition(self):
"""
:return: a string containing the table definition using the DataJoint DDL.
"""
raise NotImplementedError('Subclasses of Table must implement the property "definition"')
class Manual(UserTable):
"""
Inherit from this class if the table's values are entered manually.
"""
_prefix = r""
tier_regexp = r"(?P<manual>" + _prefix + _base_regexp + ")"
class Lookup(UserTable):
"""
Inherit from this class if the table's values are for lookup. This is
currently equivalent to defining the table as Manual and serves semantic
purposes only.
"""
_prefix = "#"
tier_regexp = r"(?P<lookup>" + _prefix + _base_regexp.replace("TIER", "lookup") + ")"
class Imported(UserTable, AutoPopulate):
"""
Inherit from this class if the table's values are imported from external data sources.
The inherited class must at least provide the function `_make_tuples`.
"""
_prefix = "_"
tier_regexp = r"(?P<imported>" + _prefix + _base_regexp + ")"
class Computed(UserTable, AutoPopulate):
"""
Inherit from this class if the table's values are computed from other tables in the schema.
The inherited class must at least provide the function `_make_tuples`.
"""
_prefix = "__"
tier_regexp = r"(?P<computed>" + _prefix + _base_regexp + ")"
class PartMeta(TableMeta):
"""Metaclass for Part tables with overridden class properties."""
@property
def table_name(cls):
"""The table name for a Part is derived from its master table."""
return None if cls.master is None else cls.master.table_name + "__" + from_camel_case(cls.__name__)
@property
def full_table_name(cls):
"""The fully qualified table name (quoted per backend)."""
if cls.database is None or cls.table_name is None:
return None
return cls._connection.adapter.make_full_table_name(cls.database, cls.table_name)
@property
def master(cls):
"""The master table for this Part table."""
return cls._master
class Part(UserTable, metaclass=PartMeta):
"""
Inherit from this class if the table's values are details of an entry in another table
and if this table is populated by the other table. For example, the entries inheriting from
dj.Part could be single entries of a matrix, while the parent table refers to the entire matrix.
Part tables are implemented as classes inside classes.
"""
_connection = None
_master = None
tier_regexp = (
r"(?P<master>"
+ "|".join([c.tier_regexp for c in (Manual, Lookup, Imported, Computed)])
+ r"){1,1}"
+ "__"
+ r"(?P<part>"
+ _base_regexp
+ ")"
)
def delete(self, part_integrity: str = "enforce", **kwargs):
"""
Delete from a Part table.
Args:
part_integrity: Policy for master-part integrity. One of:
- ``"enforce"`` (default): Error - delete from master instead.
- ``"ignore"``: Allow direct deletion (breaks master-part integrity).
- ``"cascade"``: Delete parts AND cascade up to delete master.
**kwargs: Additional arguments passed to Table.delete()
(transaction, prompt)
Raises:
DataJointError: If part_integrity="enforce" (direct Part deletes prohibited)
"""
if part_integrity == "enforce":
raise DataJointError(
"Cannot delete from a Part directly. Delete from master instead, "
"or use part_integrity='ignore' to break integrity, "
"or part_integrity='cascade' to also delete master."
)
super().delete(part_integrity=part_integrity, **kwargs)
def drop(self, part_integrity: str = "enforce"):
"""
Drop a Part table.
Args:
part_integrity: Policy for master-part integrity. One of:
- ``"enforce"`` (default): Error - drop master instead.
- ``"ignore"``: Allow direct drop (breaks master-part structure).
Note: ``"cascade"`` is not supported for drop (too destructive).
Raises:
DataJointError: If part_integrity="enforce" (direct Part drops prohibited)
"""
if part_integrity == "ignore":
super().drop()
elif part_integrity == "enforce":
raise DataJointError("Cannot drop a Part directly. Drop master instead, or use part_integrity='ignore' to force.")
else:
raise ValueError(f"part_integrity for drop must be 'enforce' or 'ignore', got {part_integrity!r}")
def alter(self, prompt=True, context=None):
# without context, use declaration context which maps master keyword to master table
super().alter(prompt=prompt, context=context or self.declaration_context)
user_table_classes = (Manual, Lookup, Computed, Imported, Part)
class _AliasNode:
"""
special class to indicate aliased foreign keys
"""
pass
def _get_tier(table_name):
"""given the table name, return the user table class."""
# Handle both MySQL backticks and PostgreSQL double quotes
if table_name.startswith("`"):
# MySQL format: `schema`.`table_name`
extracted_name = table_name.split("`")[-2]
elif table_name.startswith('"'):
# PostgreSQL format: "schema"."table_name"
extracted_name = table_name.split('"')[-2]
else:
return _AliasNode
try:
return next(tier for tier in user_table_classes if re.fullmatch(tier.tier_regexp, extracted_name))
except StopIteration:
return None