• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

datajoint / datajoint-python / #12897

pending completion
#12897

push

travis-ci

web-flow
<a href="https://github.com/datajoint/datajoint-python/commit/<a class=hub.com/datajoint/datajoint-python/commit/715ab40552f63cd79723ed2830c6691b2cb228b9">715ab4055<a href="https://github.com/datajoint/datajoint-python/commit/715ab40552f63cd79723ed2830c6691b2cb228b9">">Merge </a><a class="double-link" href="https://github.com/datajoint/datajoint-python/commit/<a class="double-link" href="https://github.com/datajoint/datajoint-python/commit/0a4f193031d8b1e14b09ec62d83c5def3b7421b0">0a4f19303</a>">0a4f19303</a><a href="https://github.com/datajoint/datajoint-python/commit/715ab40552f63cd79723ed2830c6691b2cb228b9"> into 3b6e84588">3b6e84588</a>

69 of 69 new or added lines in 9 files covered. (100.0%)

3125 of 3454 relevant lines covered (90.47%)

0.9 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.73
/datajoint/declare.py
1
"""
2
This module hosts functions to convert DataJoint table definitions into mysql table definitions, and to
3
declare the corresponding mysql tables.
4
"""
5
import re
1✔
6
import pyparsing as pp
1✔
7
import logging
1✔
8
from .errors import DataJointError, _support_filepath_types, FILEPATH_FEATURE_SWITCH
1✔
9
from .attribute_adapter import get_adapter
1✔
10
from .condition import translate_attribute
1✔
11

12
UUID_DATA_TYPE = "binary(16)"
1✔
13
MAX_TABLE_NAME_LENGTH = 64
1✔
14
CONSTANT_LITERALS = {
1✔
15
    "CURRENT_TIMESTAMP",
16
    "NULL",
17
}  # SQL literals to be used without quotes (case insensitive)
18
EXTERNAL_TABLE_ROOT = "~external"
1✔
19

20
TYPE_PATTERN = {
1✔
21
    k: re.compile(v, re.I)
22
    for k, v in dict(
23
        INTEGER=r"((tiny|small|medium|big|)int|integer)(\s*\(.+\))?(\s+unsigned)?(\s+auto_increment)?|serial$",
24
        DECIMAL=r"(decimal|numeric)(\s*\(.+\))?(\s+unsigned)?$",
25
        FLOAT=r"(double|float|real)(\s*\(.+\))?(\s+unsigned)?$",
26
        STRING=r"(var)?char\s*\(.+\)$",
27
        JSON=r"json$",
28
        ENUM=r"enum\s*\(.+\)$",
29
        BOOL=r"bool(ean)?$",  # aliased to tinyint(1)
30
        TEMPORAL=r"(date|datetime|time|timestamp|year)(\s*\(.+\))?$",
31
        INTERNAL_BLOB=r"(tiny|small|medium|long|)blob$",
32
        EXTERNAL_BLOB=r"blob@(?P<store>[a-z][\-\w]*)$",
33
        INTERNAL_ATTACH=r"attach$",
34
        EXTERNAL_ATTACH=r"attach@(?P<store>[a-z][\-\w]*)$",
35
        FILEPATH=r"filepath@(?P<store>[a-z][\-\w]*)$",
36
        UUID=r"uuid$",
37
        ADAPTED=r"<.+>$",
38
    ).items()
39
}
40

41
# custom types are stored in attribute comment
42
SPECIAL_TYPES = {
1✔
43
    "UUID",
44
    "INTERNAL_ATTACH",
45
    "EXTERNAL_ATTACH",
46
    "EXTERNAL_BLOB",
47
    "FILEPATH",
48
    "ADAPTED",
49
}
50
NATIVE_TYPES = set(TYPE_PATTERN) - SPECIAL_TYPES
1✔
51
EXTERNAL_TYPES = {
1✔
52
    "EXTERNAL_ATTACH",
53
    "EXTERNAL_BLOB",
54
    "FILEPATH",
55
}  # data referenced by a UUID in external tables
56
SERIALIZED_TYPES = {
1✔
57
    "EXTERNAL_ATTACH",
58
    "INTERNAL_ATTACH",
59
    "EXTERNAL_BLOB",
60
    "INTERNAL_BLOB",
61
}  # requires packing data
62

63
assert set().union(SPECIAL_TYPES, EXTERNAL_TYPES, SERIALIZED_TYPES) <= set(TYPE_PATTERN)
1✔
64

65

66
def match_type(attribute_type):
1✔
67
    try:
1✔
68
        return next(
1✔
69
            category
70
            for category, pattern in TYPE_PATTERN.items()
71
            if pattern.match(attribute_type)
72
        )
73
    except StopIteration:
1✔
74
        raise DataJointError(
1✔
75
            "Unsupported attribute type {type}".format(type=attribute_type)
76
        )
77

78

79
logger = logging.getLogger(__name__.split(".")[0])
1✔
80

81

82
def build_foreign_key_parser_old():
1✔
83
    # old-style foreign key parser. Superseded by expression-based syntax. See issue #436
84
    # This will be deprecated in a future release.
85
    left = pp.Literal("(").suppress()
1✔
86
    right = pp.Literal(")").suppress()
1✔
87
    attribute_name = pp.Word(pp.srange("[a-z]"), pp.srange("[a-z0-9_]"))
1✔
88
    new_attrs = pp.Optional(
1✔
89
        left + pp.delimitedList(attribute_name) + right
90
    ).setResultsName("new_attrs")
91
    arrow = pp.Literal("->").suppress()
1✔
92
    lbracket = pp.Literal("[").suppress()
1✔
93
    rbracket = pp.Literal("]").suppress()
1✔
94
    option = pp.Word(pp.srange("[a-zA-Z]"))
1✔
95
    options = pp.Optional(
1✔
96
        lbracket + pp.delimitedList(option) + rbracket
97
    ).setResultsName("options")
98
    ref_table = pp.Word(pp.alphas, pp.alphanums + "._").setResultsName("ref_table")
1✔
99
    ref_attrs = pp.Optional(
1✔
100
        left + pp.delimitedList(attribute_name) + right
101
    ).setResultsName("ref_attrs")
102
    return new_attrs + arrow + options + ref_table + ref_attrs
1✔
103

104

105
def build_foreign_key_parser():
1✔
106
    arrow = pp.Literal("->").suppress()
1✔
107
    lbracket = pp.Literal("[").suppress()
1✔
108
    rbracket = pp.Literal("]").suppress()
1✔
109
    option = pp.Word(pp.srange("[a-zA-Z]"))
1✔
110
    options = pp.Optional(
1✔
111
        lbracket + pp.delimitedList(option) + rbracket
112
    ).setResultsName("options")
113
    ref_table = pp.restOfLine.setResultsName("ref_table")
1✔
114
    return arrow + options + ref_table
1✔
115

116

117
def build_attribute_parser():
1✔
118
    quoted = pp.QuotedString('"') ^ pp.QuotedString("'")
1✔
119
    colon = pp.Literal(":").suppress()
1✔
120
    attribute_name = pp.Word(pp.srange("[a-z]"), pp.srange("[a-z0-9_]")).setResultsName(
1✔
121
        "name"
122
    )
123
    data_type = (
1✔
124
        pp.Combine(pp.Word(pp.alphas) + pp.SkipTo("#", ignore=quoted))
125
        ^ pp.QuotedString("<", endQuoteChar=">", unquoteResults=False)
126
    ).setResultsName("type")
127
    default = pp.Literal("=").suppress() + pp.SkipTo(
1✔
128
        colon, ignore=quoted
129
    ).setResultsName("default")
130
    comment = pp.Literal("#").suppress() + pp.restOfLine.setResultsName("comment")
1✔
131
    return attribute_name + pp.Optional(default) + colon + data_type + comment
1✔
132

133

134
foreign_key_parser_old = build_foreign_key_parser_old()
1✔
135
foreign_key_parser = build_foreign_key_parser()
1✔
136
attribute_parser = build_attribute_parser()
1✔
137

138

139
def is_foreign_key(line):
1✔
140
    """
141

142
    :param line: a line from the table definition
143
    :return: true if the line appears to be a foreign key definition
144
    """
145
    arrow_position = line.find("->")
1✔
146
    return arrow_position >= 0 and not any(c in line[:arrow_position] for c in "\"#'")
1✔
147

148

149
def compile_foreign_key(
1✔
150
    line, context, attributes, primary_key, attr_sql, foreign_key_sql, index_sql
151
):
152
    """
153
    :param line: a line from a table definition
154
    :param context: namespace containing referenced objects
155
    :param attributes: list of attribute names already in the declaration -- to be updated by this function
156
    :param primary_key: None if the current foreign key is made from the dependent section. Otherwise it is the list
157
        of primary key attributes thus far -- to be updated by the function
158
    :param attr_sql: list of sql statements defining attributes -- to be updated by this function.
159
    :param foreign_key_sql: list of sql statements specifying foreign key constraints -- to be updated by this function.
160
    :param index_sql: list of INDEX declaration statements, duplicate or redundant indexes are ok.
161
    """
162
    # Parse and validate
163
    from .table import Table
1✔
164
    from .expression import QueryExpression
1✔
165

166
    obsolete = False  # See issue #436.  Old style to be deprecated in a future release
1✔
167
    try:
1✔
168
        result = foreign_key_parser.parseString(line)
1✔
169
    except pp.ParseException:
1✔
170
        try:
1✔
171
            result = foreign_key_parser_old.parseString(line)
1✔
172
        except pp.ParseBaseException as err:
1✔
173
            raise DataJointError('Parsing error in line "%s". %s.' % (line, err))
1✔
174
        else:
175
            obsolete = True
1✔
176
    try:
1✔
177
        ref = eval(result.ref_table, context)
1✔
178
    except NameError if obsolete else Exception:
1✔
179
        raise DataJointError(
1✔
180
            "Foreign key reference %s could not be resolved" % result.ref_table
181
        )
182

183
    options = [opt.upper() for opt in result.options]
1✔
184
    for opt in options:  # check for invalid options
1✔
185
        if opt not in {"NULLABLE", "UNIQUE"}:
1✔
186
            raise DataJointError('Invalid foreign key option "{opt}"'.format(opt=opt))
×
187
    is_nullable = "NULLABLE" in options
1✔
188
    is_unique = "UNIQUE" in options
1✔
189
    if is_nullable and primary_key is not None:
1✔
190
        raise DataJointError(
×
191
            'Primary dependencies cannot be nullable in line "{line}"'.format(line=line)
192
        )
193

194
    if obsolete:
1✔
195
        logger.warning(
1✔
196
            'Line "{line}" uses obsolete syntax that will no longer be supported in datajoint 0.14. '
197
            "For details, see issue #780 https://github.com/datajoint/datajoint-python/issues/780".format(
198
                line=line
199
            )
200
        )
201
        if not isinstance(ref, type) or not issubclass(ref, Table):
1✔
202
            raise DataJointError(
×
203
                "Foreign key reference %r must be a valid query" % result.ref_table
204
            )
205

206
    if isinstance(ref, type) and issubclass(ref, Table):
1✔
207
        ref = ref()
1✔
208

209
    # check that dependency is of a supported type
210
    if (
1✔
211
        not isinstance(ref, QueryExpression)
212
        or len(ref.restriction)
213
        or len(ref.support) != 1
214
        or not isinstance(ref.support[0], str)
215
    ):
216
        raise DataJointError(
×
217
            'Dependency "%s" is not supported (yet). Use a base table or its projection.'
218
            % result.ref_table
219
        )
220

221
    if obsolete:
1✔
222
        # for backward compatibility with old-style dependency declarations.  See issue #436
223
        if not isinstance(ref, Table):
1✔
224
            DataJointError(
×
225
                'Dependency "%s" is not supported. Check documentation.'
226
                % result.ref_table
227
            )
228
        if not all(r in ref.primary_key for r in result.ref_attrs):
1✔
229
            raise DataJointError('Invalid foreign key attributes in "%s"' % line)
×
230
        try:
1✔
231
            raise DataJointError(
1✔
232
                'Duplicate attributes "{attr}" in "{line}"'.format(
233
                    attr=next(attr for attr in result.new_attrs if attr in attributes),
234
                    line=line,
235
                )
236
            )
237
        except StopIteration:
1✔
238
            pass  # the normal outcome
1✔
239

240
        # Match the primary attributes of the referenced table to local attributes
241
        new_attrs = list(result.new_attrs)
1✔
242
        ref_attrs = list(result.ref_attrs)
1✔
243

244
        # special case, the renamed attribute is implicit
245
        if new_attrs and not ref_attrs:
1✔
246
            if len(new_attrs) != 1:
1✔
247
                raise DataJointError(
×
248
                    'Renamed foreign key must be mapped to the primary key in "%s"'
249
                    % line
250
                )
251
            if len(ref.primary_key) == 1:
1✔
252
                # if the primary key has one attribute, allow implicit renaming
253
                ref_attrs = ref.primary_key
1✔
254
            else:
255
                # if only one primary key attribute remains, then allow implicit renaming
256
                ref_attrs = [attr for attr in ref.primary_key if attr not in attributes]
1✔
257
                if len(ref_attrs) != 1:
1✔
258
                    raise DataJointError(
×
259
                        'Could not resolve which primary key attribute should be referenced in "%s"'
260
                        % line
261
                    )
262

263
        if len(new_attrs) != len(ref_attrs):
1✔
264
            raise DataJointError('Mismatched attributes in foreign key "%s"' % line)
×
265

266
        if ref_attrs:
1✔
267
            # convert to projected dependency
268
            ref = ref.proj(**dict(zip(new_attrs, ref_attrs)))
1✔
269

270
    # declare new foreign key attributes
271
    for attr in ref.primary_key:
1✔
272
        if attr not in attributes:
1✔
273
            attributes.append(attr)
1✔
274
            if primary_key is not None:
1✔
275
                primary_key.append(attr)
1✔
276
            attr_sql.append(
1✔
277
                ref.heading[attr].sql.replace("NOT NULL ", "", int(is_nullable))
278
            )
279

280
    # declare the foreign key
281
    foreign_key_sql.append(
1✔
282
        "FOREIGN KEY (`{fk}`) REFERENCES {ref} (`{pk}`) ON UPDATE CASCADE ON DELETE RESTRICT".format(
283
            fk="`,`".join(ref.primary_key),
284
            pk="`,`".join(ref.heading[name].original_name for name in ref.primary_key),
285
            ref=ref.support[0],
286
        )
287
    )
288

289
    # declare unique index
290
    if is_unique:
1✔
291
        index_sql.append(
1✔
292
            "UNIQUE INDEX ({attrs})".format(
293
                attrs=",".join("`%s`" % attr for attr in ref.primary_key)
294
            )
295
        )
296

297

298
def prepare_declare(definition, context):
1✔
299
    # split definition into lines
300
    definition = re.split(r"\s*\n\s*", definition.strip())
1✔
301
    # check for optional table comment
302
    table_comment = (
1✔
303
        definition.pop(0)[1:].strip() if definition[0].startswith("#") else ""
304
    )
305
    if table_comment.startswith(":"):
1✔
306
        raise DataJointError('Table comment must not start with a colon ":"')
×
307
    in_key = True  # parse primary keys
1✔
308
    primary_key = []
1✔
309
    attributes = []
1✔
310
    attribute_sql = []
1✔
311
    foreign_key_sql = []
1✔
312
    index_sql = []
1✔
313
    external_stores = []
1✔
314

315
    for line in definition:
1✔
316
        if not line or line.startswith("#"):  # ignore additional comments
1✔
317
            pass
1✔
318
        elif line.startswith("---") or line.startswith("___"):
1✔
319
            in_key = False  # start parsing dependent attributes
1✔
320
        elif is_foreign_key(line):
1✔
321
            compile_foreign_key(
1✔
322
                line,
323
                context,
324
                attributes,
325
                primary_key if in_key else None,
326
                attribute_sql,
327
                foreign_key_sql,
328
                index_sql,
329
            )
330
        elif re.match(r"^(unique\s+)?index\s*.*$", line, re.I):  # index
1✔
331
            compile_index(line, index_sql)
1✔
332
        else:
333
            name, sql, store = compile_attribute(line, in_key, foreign_key_sql, context)
1✔
334
            if store:
1✔
335
                external_stores.append(store)
1✔
336
            if in_key and name not in primary_key:
1✔
337
                primary_key.append(name)
1✔
338
            if name not in attributes:
1✔
339
                attributes.append(name)
1✔
340
                attribute_sql.append(sql)
1✔
341

342
    return (
1✔
343
        table_comment,
344
        primary_key,
345
        attribute_sql,
346
        foreign_key_sql,
347
        index_sql,
348
        external_stores,
349
    )
350

351

352
def declare(full_table_name, definition, context):
1✔
353
    """
354
    Parse declaration and generate the SQL CREATE TABLE code
355

356
    :param full_table_name: full name of the table
357
    :param definition: DataJoint table definition
358
    :param context: dictionary of objects that might be referred to in the table
359
    :return: SQL CREATE TABLE statement, list of external stores used
360
    """
361
    table_name = full_table_name.strip("`").split(".")[1]
1✔
362
    if len(table_name) > MAX_TABLE_NAME_LENGTH:
1✔
363
        raise DataJointError(
1✔
364
            "Table name `{name}` exceeds the max length of {max_length}".format(
365
                name=table_name, max_length=MAX_TABLE_NAME_LENGTH
366
            )
367
        )
368

369
    (
1✔
370
        table_comment,
371
        primary_key,
372
        attribute_sql,
373
        foreign_key_sql,
374
        index_sql,
375
        external_stores,
376
    ) = prepare_declare(definition, context)
377

378
    if not primary_key:
1✔
379
        raise DataJointError("Table must have a primary key")
×
380

381
    return (
1✔
382
        "CREATE TABLE IF NOT EXISTS %s (\n" % full_table_name
383
        + ",\n".join(
384
            attribute_sql
385
            + ["PRIMARY KEY (`" + "`,`".join(primary_key) + "`)"]
386
            + foreign_key_sql
387
            + index_sql
388
        )
389
        + '\n) ENGINE=InnoDB, COMMENT "%s"' % table_comment
390
    ), external_stores
391

392

393
def _make_attribute_alter(new, old, primary_key):
1✔
394
    """
395
    :param new: new attribute declarations
396
    :param old: old attribute declarations
397
    :param primary_key: primary key attributes
398
    :return: list of SQL ALTER commands
399
    """
400
    # parse attribute names
401
    name_regexp = re.compile(r"^`(?P<name>\w+)`")
1✔
402
    original_regexp = re.compile(r'COMMENT "{\s*(?P<name>\w+)\s*}')
1✔
403
    matched = ((name_regexp.match(d), original_regexp.search(d)) for d in new)
1✔
404
    new_names = dict((d.group("name"), n and n.group("name")) for d, n in matched)
1✔
405
    old_names = [name_regexp.search(d).group("name") for d in old]
1✔
406

407
    # verify that original names are only used once
408
    renamed = set()
1✔
409
    for v in new_names.values():
1✔
410
        if v:
1✔
411
            if v in renamed:
1✔
412
                raise DataJointError(
×
413
                    "Alter attempted to rename attribute {%s} twice." % v
414
                )
415
            renamed.add(v)
1✔
416

417
    # verify that all renamed attributes existed in the old definition
418
    try:
1✔
419
        raise DataJointError(
1✔
420
            "Attribute {} does not exist in the original definition".format(
421
                next(attr for attr in renamed if attr not in old_names)
422
            )
423
        )
424
    except StopIteration:
1✔
425
        pass
1✔
426

427
    # dropping attributes
428
    to_drop = [n for n in old_names if n not in renamed and n not in new_names]
1✔
429
    sql = ["DROP `%s`" % n for n in to_drop]
1✔
430
    old_names = [name for name in old_names if name not in to_drop]
1✔
431

432
    # add or change attributes in order
433
    prev = None
1✔
434
    for new_def, (new_name, old_name) in zip(new, new_names.items()):
1✔
435
        if new_name not in primary_key:
1✔
436
            after = None  # if None, then must include the AFTER clause
1✔
437
            if prev:
1✔
438
                try:
1✔
439
                    idx = old_names.index(old_name or new_name)
1✔
440
                except ValueError:
1✔
441
                    after = prev[0]
1✔
442
                else:
443
                    if idx >= 1 and old_names[idx - 1] != (prev[1] or prev[0]):
1✔
444
                        after = prev[0]
1✔
445
            if new_def not in old or after:
1✔
446
                sql.append(
1✔
447
                    "{command} {new_def} {after}".format(
448
                        command=(
449
                            "ADD"
450
                            if (old_name or new_name) not in old_names
451
                            else "MODIFY"
452
                            if not old_name
453
                            else "CHANGE `%s`" % old_name
454
                        ),
455
                        new_def=new_def,
456
                        after="" if after is None else "AFTER `%s`" % after,
457
                    )
458
                )
459
        prev = new_name, old_name
1✔
460

461
    return sql
1✔
462

463

464
def alter(definition, old_definition, context):
1✔
465
    """
466
    :param definition: new table definition
467
    :param old_definition: current table definition
468
    :param context: the context in which to evaluate foreign key definitions
469
    :return: string SQL ALTER command, list of new stores used for external storage
470
    """
471
    (
1✔
472
        table_comment,
473
        primary_key,
474
        attribute_sql,
475
        foreign_key_sql,
476
        index_sql,
477
        external_stores,
478
    ) = prepare_declare(definition, context)
479
    (
1✔
480
        table_comment_,
481
        primary_key_,
482
        attribute_sql_,
483
        foreign_key_sql_,
484
        index_sql_,
485
        external_stores_,
486
    ) = prepare_declare(old_definition, context)
487

488
    # analyze differences between declarations
489
    sql = list()
1✔
490
    if primary_key != primary_key_:
1✔
491
        raise NotImplementedError("table.alter cannot alter the primary key (yet).")
×
492
    if foreign_key_sql != foreign_key_sql_:
1✔
493
        raise NotImplementedError("table.alter cannot alter foreign keys (yet).")
×
494
    if index_sql != index_sql_:
1✔
495
        raise NotImplementedError("table.alter cannot alter indexes (yet)")
×
496
    if attribute_sql != attribute_sql_:
1✔
497
        sql.extend(_make_attribute_alter(attribute_sql, attribute_sql_, primary_key))
1✔
498
    if table_comment != table_comment_:
1✔
499
        sql.append('COMMENT="%s"' % table_comment)
1✔
500
    return sql, [e for e in external_stores if e not in external_stores_]
1✔
501

502

503
def compile_index(line, index_sql):
1✔
504
    def format_attribute(attr):
1✔
505
        match, attr = translate_attribute(attr)
1✔
506
        if match is None:
1✔
507
            return attr
×
508
        elif match["path"] is None:
1✔
509
            return f"`{attr}`"
1✔
510
        else:
511
            return f"({attr})"
×
512

513
    match = re.match(
1✔
514
        r"(?P<unique>unique\s+)?index\s*\(\s*(?P<args>.*)\)", line, re.I
515
    ).groupdict()
516
    attr_list = re.findall(r"(?:[^,(]|\([^)]*\))+", match["args"])
1✔
517
    index_sql.append(
1✔
518
        "{unique}index ({attrs})".format(
519
            unique="unique " if match["unique"] else "",
520
            attrs=",".join(format_attribute(a.strip()) for a in attr_list),
521
        )
522
    )
523

524

525
def substitute_special_type(match, category, foreign_key_sql, context):
1✔
526
    """
527
    :param match: dict containing with keys "type" and "comment" -- will be modified in place
528
    :param category: attribute type category from TYPE_PATTERN
529
    :param foreign_key_sql: list of foreign key declarations to add to
530
    :param context: context for looking up user-defined attribute_type adapters
531
    """
532
    if category == "UUID":
1✔
533
        match["type"] = UUID_DATA_TYPE
1✔
534
    elif category == "INTERNAL_ATTACH":
1✔
535
        match["type"] = "LONGBLOB"
1✔
536
    elif category in EXTERNAL_TYPES:
1✔
537
        if category == "FILEPATH" and not _support_filepath_types():
1✔
538
            raise DataJointError(
×
539
                """
540
            The filepath data type is disabled until complete validation.
541
            To turn it on as experimental feature, set the environment variable
542
            {env} = TRUE or upgrade datajoint.
543
            """.format(
544
                    env=FILEPATH_FEATURE_SWITCH
545
                )
546
            )
547
        match["store"] = match["type"].split("@", 1)[1]
1✔
548
        match["type"] = UUID_DATA_TYPE
1✔
549
        foreign_key_sql.append(
1✔
550
            "FOREIGN KEY (`{name}`) REFERENCES `{{database}}`.`{external_table_root}_{store}` (`hash`) "
551
            "ON UPDATE RESTRICT ON DELETE RESTRICT".format(
552
                external_table_root=EXTERNAL_TABLE_ROOT, **match
553
            )
554
        )
555
    elif category == "ADAPTED":
1✔
556
        adapter = get_adapter(context, match["type"])
1✔
557
        match["type"] = adapter.attribute_type
1✔
558
        category = match_type(match["type"])
1✔
559
        if category in SPECIAL_TYPES:
1✔
560
            # recursive redefinition from user-defined datatypes.
561
            substitute_special_type(match, category, foreign_key_sql, context)
1✔
562
    else:
563
        assert False, "Unknown special type"
×
564

565

566
def compile_attribute(line, in_key, foreign_key_sql, context):
1✔
567
    """
568
    Convert attribute definition from DataJoint format to SQL
569

570
    :param line: attribution line
571
    :param in_key: set to True if attribute is in primary key set
572
    :param foreign_key_sql: the list of foreign key declarations to add to
573
    :param context: context in which to look up user-defined attribute type adapterss
574
    :returns: (name, sql, is_external) -- attribute name and sql code for its declaration
575
    """
576
    try:
1✔
577
        match = attribute_parser.parseString(line + "#", parseAll=True)
1✔
578
    except pp.ParseException as err:
1✔
579
        raise DataJointError(
1✔
580
            "Declaration error in position {pos} in line:\n  {line}\n{msg}".format(
581
                line=err.args[0], pos=err.args[1], msg=err.args[2]
582
            )
583
        )
584
    match["comment"] = match["comment"].rstrip("#")
1✔
585
    if "default" not in match:
1✔
586
        match["default"] = ""
1✔
587
    match = {k: v.strip() for k, v in match.items()}
1✔
588
    match["nullable"] = match["default"].lower() == "null"
1✔
589

590
    if match["nullable"]:
1✔
591
        if in_key:
1✔
592
            raise DataJointError(
×
593
                'Primary key attributes cannot be nullable in line "%s"' % line
594
            )
595
        match["default"] = "DEFAULT NULL"  # nullable attributes default to null
1✔
596
    else:
597
        if match["default"]:
1✔
598
            quote = (
1✔
599
                match["default"].split("(")[0].upper() not in CONSTANT_LITERALS
600
                and match["default"][0] not in "\"'"
601
            )
602
            match["default"] = (
1✔
603
                "NOT NULL DEFAULT " + ('"%s"' if quote else "%s") % match["default"]
604
            )
605
        else:
606
            match["default"] = "NOT NULL"
1✔
607

608
    match["comment"] = match["comment"].replace(
1✔
609
        '"', '\\"'
610
    )  # escape double quotes in comment
611

612
    if match["comment"].startswith(":"):
1✔
613
        raise DataJointError(
×
614
            'An attribute comment must not start with a colon in comment "{comment}"'.format(
615
                **match
616
            )
617
        )
618

619
    category = match_type(match["type"])
1✔
620
    if category in SPECIAL_TYPES:
1✔
621
        match["comment"] = ":{type}:{comment}".format(
1✔
622
            **match
623
        )  # insert custom type into comment
624
        substitute_special_type(match, category, foreign_key_sql, context)
1✔
625

626
    if category in SERIALIZED_TYPES and match["default"] not in {
1✔
627
        "DEFAULT NULL",
628
        "NOT NULL",
629
    }:
630
        raise DataJointError(
×
631
            "The default value for a blob or attachment attributes can only be NULL in:\n{line}".format(
632
                line=line
633
            )
634
        )
635

636
    sql = (
1✔
637
        "`{name}` {type} {default}"
638
        + (' COMMENT "{comment}"' if match["comment"] else "")
639
    ).format(**match)
640
    return match["name"], sql, match.get("store")
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc