deriva 1.7.1__py3-none-any.whl → 1.7.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deriva/config/annotation_config.py +2 -2
- deriva/config/rollback_annotation.py +1 -1
- deriva/core/__init__.py +1 -1
- deriva/core/datapath.py +203 -21
- deriva/core/ermrest_catalog.py +103 -23
- deriva/core/ermrest_model.py +955 -59
- deriva/core/hatrac_store.py +9 -20
- deriva/core/mmo.py +379 -0
- deriva/core/utils/globus_auth_utils.py +3 -1
- deriva/transfer/download/processors/postprocess/transfer_post_processor.py +2 -2
- deriva/transfer/download/processors/query/base_query_processor.py +2 -1
- deriva/transfer/upload/deriva_upload.py +5 -2
- {deriva-1.7.1.dist-info → deriva-1.7.4.dist-info}/METADATA +1 -1
- {deriva-1.7.1.dist-info → deriva-1.7.4.dist-info}/RECORD +27 -18
- tests/deriva/core/mmo/__init__.py +0 -0
- tests/deriva/core/mmo/base.py +300 -0
- tests/deriva/core/mmo/test_mmo_drop.py +252 -0
- tests/deriva/core/mmo/test_mmo_find.py +90 -0
- tests/deriva/core/mmo/test_mmo_prune.py +196 -0
- tests/deriva/core/mmo/test_mmo_rename.py +222 -0
- tests/deriva/core/mmo/test_mmo_replace.py +180 -0
- tests/deriva/core/test_datapath.py +52 -26
- tests/deriva/core/test_ermrest_model.py +782 -0
- {deriva-1.7.1.dist-info → deriva-1.7.4.dist-info}/LICENSE +0 -0
- {deriva-1.7.1.dist-info → deriva-1.7.4.dist-info}/WHEEL +0 -0
- {deriva-1.7.1.dist-info → deriva-1.7.4.dist-info}/entry_points.txt +0 -0
- {deriva-1.7.1.dist-info → deriva-1.7.4.dist-info}/top_level.txt +0 -0
deriva/core/ermrest_model.py
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
|
|
2
|
-
from
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import hashlib
|
|
3
6
|
import json
|
|
4
7
|
import re
|
|
8
|
+
from collections import OrderedDict
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from enum import Enum
|
|
11
|
+
|
|
12
|
+
from . import AttrDict, tag, urlquote, stob, mmo
|
|
5
13
|
|
|
6
|
-
from . import AttrDict, tag, urlquote, stob
|
|
7
14
|
|
|
8
15
|
class NoChange (object):
|
|
9
16
|
"""Special class used to distinguish no-change default arguments to methods.
|
|
@@ -17,6 +24,93 @@ class NoChange (object):
|
|
|
17
24
|
# singletone to use in APIs below
|
|
18
25
|
nochange = NoChange()
|
|
19
26
|
|
|
27
|
+
class UpdateMappings (str, Enum):
|
|
28
|
+
"""Update Mappings flag enum"""
|
|
29
|
+
no_update = ''
|
|
30
|
+
deferred = 'deferred'
|
|
31
|
+
immediate = 'immediate'
|
|
32
|
+
|
|
33
|
+
def make_id(*components):
|
|
34
|
+
"""Build an identifier that will be OK for ERMrest and Postgres.
|
|
35
|
+
|
|
36
|
+
Naively, append as '_'.join(components).
|
|
37
|
+
|
|
38
|
+
Fallback to heuristics mixing truncation with short hashes.
|
|
39
|
+
"""
|
|
40
|
+
# accept lists at top-level for convenience (compound keys, etc.)
|
|
41
|
+
expanded = []
|
|
42
|
+
for e in components:
|
|
43
|
+
if isinstance(e, list):
|
|
44
|
+
expanded.extend(e)
|
|
45
|
+
else:
|
|
46
|
+
expanded.append(e)
|
|
47
|
+
|
|
48
|
+
# prefer to use naive name as requested
|
|
49
|
+
naive_result = '_'.join(expanded)
|
|
50
|
+
naive_len = len(naive_result.encode('utf8'))
|
|
51
|
+
if naive_len <= 63:
|
|
52
|
+
return naive_result
|
|
53
|
+
|
|
54
|
+
# we'll need to truncate and hash in some way...
|
|
55
|
+
def hash(s, nbytes):
|
|
56
|
+
return base64.urlsafe_b64encode(hashlib.md5(s.encode('utf8')).digest()).decode()[0:nbytes]
|
|
57
|
+
|
|
58
|
+
def truncate(s, maxlen):
|
|
59
|
+
encoded_len = len(s.encode('utf8'))
|
|
60
|
+
# we need to chop whole (unicode) chars but test encoded byte lengths!
|
|
61
|
+
for i in range(max(1, len(s) - maxlen), len(s) - 1):
|
|
62
|
+
result = s[0:-1 * i].rstrip()
|
|
63
|
+
if len(result.encode('utf8')) <= (maxlen - 2):
|
|
64
|
+
return result + '..'
|
|
65
|
+
return s
|
|
66
|
+
|
|
67
|
+
naive_hash = hash(naive_result, 5)
|
|
68
|
+
parts = [
|
|
69
|
+
(i, expanded[i])
|
|
70
|
+
for i in range(len(expanded))
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
# try to find a solution truncating individual fields
|
|
74
|
+
for maxlen in [15, 12, 9]:
|
|
75
|
+
parts.sort(key=lambda p: (len(p[1].encode('utf8')), p[0]), reverse=True)
|
|
76
|
+
for i in range(len(parts)):
|
|
77
|
+
idx, part = parts[i]
|
|
78
|
+
if len(part.encode('utf8')) > maxlen:
|
|
79
|
+
parts[i] = (idx, truncate(part, maxlen))
|
|
80
|
+
candidate_result = '_'.join([
|
|
81
|
+
p[1]
|
|
82
|
+
for p in sorted(parts, key=lambda p: p[0])
|
|
83
|
+
] + [naive_hash])
|
|
84
|
+
if len(candidate_result.encode('utf8')) < 63:
|
|
85
|
+
return candidate_result
|
|
86
|
+
|
|
87
|
+
# fallback to truncating original naive name
|
|
88
|
+
# try to preserve suffix and trim in middle
|
|
89
|
+
result = ''.join([
|
|
90
|
+
truncate(naive_result, len(naive_result)//3),
|
|
91
|
+
naive_result[-len(naive_result)//3:],
|
|
92
|
+
'_',
|
|
93
|
+
naive_hash
|
|
94
|
+
])
|
|
95
|
+
if len(result.encode('utf8')) <= 63:
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
# last-ditch (e.g. multibyte unicode suffix worst case)
|
|
99
|
+
return truncate(naive_result, 55) + naive_hash
|
|
100
|
+
|
|
101
|
+
def sql_identifier(s):
|
|
102
|
+
# double " to protect from SQL
|
|
103
|
+
return '"%s"' % (s.replace('"', '""'))
|
|
104
|
+
|
|
105
|
+
def sql_literal(v):
|
|
106
|
+
if v is None:
|
|
107
|
+
return 'NULL'
|
|
108
|
+
if type(v) is list:
|
|
109
|
+
s = json.dumps(v)
|
|
110
|
+
# double ' to protect from SQL
|
|
111
|
+
s = '%s' % v
|
|
112
|
+
return "'%s'" % (s.replace("'", "''"))
|
|
113
|
+
|
|
20
114
|
def presence_annotation(tag_uri):
|
|
21
115
|
"""Decorator to establish property getter/setter/deleter for presence annotations.
|
|
22
116
|
|
|
@@ -447,6 +541,15 @@ class Schema (object):
|
|
|
447
541
|
for tname, tdoc in schema_doc.get('tables', {}).items()
|
|
448
542
|
}
|
|
449
543
|
|
|
544
|
+
def __repr__(self):
|
|
545
|
+
cls = type(self)
|
|
546
|
+
return "<%s.%s object %r at 0x%x>" % (
|
|
547
|
+
cls.__module__,
|
|
548
|
+
cls.__name__,
|
|
549
|
+
self.name,
|
|
550
|
+
id(self),
|
|
551
|
+
)
|
|
552
|
+
|
|
450
553
|
@property
|
|
451
554
|
def catalog(self):
|
|
452
555
|
return self.model.catalog
|
|
@@ -551,16 +654,16 @@ class Schema (object):
|
|
|
551
654
|
for tname, table in self.tables.items():
|
|
552
655
|
table.apply(existing.tables[tname] if existing else None)
|
|
553
656
|
|
|
554
|
-
def alter(self, schema_name=nochange, comment=nochange, acls=nochange, annotations=nochange):
|
|
657
|
+
def alter(self, schema_name=nochange, comment=nochange, acls=nochange, annotations=nochange, update_mappings=UpdateMappings.no_update):
|
|
555
658
|
"""Alter existing schema definition.
|
|
556
659
|
|
|
557
660
|
:param schema_name: Replacement schema name (default nochange)
|
|
558
661
|
:param comment: Replacement comment (default nochange)
|
|
559
662
|
:param acls: Replacement ACL configuration (default nochange)
|
|
560
663
|
:param annotations: Replacement annotations (default nochange)
|
|
664
|
+
:param update_mappings: Update annotations to reflect changes (default UpdateMappings.no_updates)
|
|
561
665
|
|
|
562
666
|
Returns self (to allow for optional chained access).
|
|
563
|
-
|
|
564
667
|
"""
|
|
565
668
|
changes = strip_nochange({
|
|
566
669
|
'schema_name': schema_name,
|
|
@@ -574,9 +677,14 @@ class Schema (object):
|
|
|
574
677
|
changed = r.json() # use changed vs changes to get server-digested values
|
|
575
678
|
|
|
576
679
|
if 'schema_name' in changes:
|
|
680
|
+
old_schema_name = self.name
|
|
577
681
|
del self.model.schemas[self.name]
|
|
578
682
|
self.name = changed['schema_name']
|
|
579
683
|
self.model.schemas[self.name] = self
|
|
684
|
+
if update_mappings:
|
|
685
|
+
mmo.replace(self.model, [old_schema_name, None], [self.name, None])
|
|
686
|
+
if update_mappings == UpdateMappings.immediate:
|
|
687
|
+
self.model.apply()
|
|
580
688
|
|
|
581
689
|
if 'comment' in changes:
|
|
582
690
|
self.comment = changed['comment']
|
|
@@ -612,11 +720,19 @@ class Schema (object):
|
|
|
612
720
|
self.model.digest_fkeys()
|
|
613
721
|
return newtable
|
|
614
722
|
|
|
615
|
-
def drop(self):
|
|
723
|
+
def drop(self, cascade=False, update_mappings=UpdateMappings.no_update):
|
|
616
724
|
"""Remove this schema from the remote database.
|
|
725
|
+
|
|
726
|
+
:param cascade: drop dependent objects.
|
|
727
|
+
:param update_mappings: Update annotations to reflect changes (default UpdateMappings.no_updates)
|
|
617
728
|
"""
|
|
618
729
|
if self.name not in self.model.schemas:
|
|
619
730
|
raise ValueError('Schema %s does not appear to belong to model.' % (self,))
|
|
731
|
+
|
|
732
|
+
if cascade:
|
|
733
|
+
for table in list(self.tables.values()):
|
|
734
|
+
table.drop(cascade=True, update_mappings=update_mappings)
|
|
735
|
+
|
|
620
736
|
self.catalog.delete(self.uri_path).raise_for_status()
|
|
621
737
|
del self.model.schemas[self.name]
|
|
622
738
|
for table in self.tables.values():
|
|
@@ -684,9 +800,20 @@ class KeyedList (list):
|
|
|
684
800
|
list.append(self, e)
|
|
685
801
|
self.elements[e.name] = e
|
|
686
802
|
|
|
803
|
+
class FindAssociationResult (object):
|
|
804
|
+
"""Wrapper for results of Table.find_associations()"""
|
|
805
|
+
def __init__(self, table, self_fkey, other_fkeys):
|
|
806
|
+
self.table = table
|
|
807
|
+
self.name = table.name
|
|
808
|
+
self.schema = table.schema
|
|
809
|
+
self.self_fkey = self_fkey
|
|
810
|
+
self.other_fkeys = other_fkeys
|
|
811
|
+
|
|
687
812
|
class Table (object):
|
|
688
813
|
"""Named table.
|
|
689
814
|
"""
|
|
815
|
+
default_key_column_search_order = ["Name", "name", "ID", "id"]
|
|
816
|
+
|
|
690
817
|
def __init__(self, schema, tname, table_doc):
|
|
691
818
|
self.schema = schema
|
|
692
819
|
self.name = tname
|
|
@@ -709,6 +836,16 @@ class Table (object):
|
|
|
709
836
|
])
|
|
710
837
|
self.referenced_by = KeyedList([])
|
|
711
838
|
|
|
839
|
+
def __repr__(self):
|
|
840
|
+
cls = type(self)
|
|
841
|
+
return "<%s.%s object %r.%r at 0x%x>" % (
|
|
842
|
+
cls.__module__,
|
|
843
|
+
cls.__name__,
|
|
844
|
+
self.schema.name if self.schema is not None else None,
|
|
845
|
+
self.name,
|
|
846
|
+
id(self),
|
|
847
|
+
)
|
|
848
|
+
|
|
712
849
|
@property
|
|
713
850
|
def columns(self):
|
|
714
851
|
"""Sugared access to self.column_definitions"""
|
|
@@ -742,20 +879,211 @@ class Table (object):
|
|
|
742
879
|
def system_key_defs(cls, custom=[]):
|
|
743
880
|
"""Build standard system key definitions, merging optional custom definitions."""
|
|
744
881
|
def ktup(k):
|
|
745
|
-
return
|
|
882
|
+
return frozenset(k['unique_columns'])
|
|
883
|
+
customized = { ktup(kdef): kdef for kdef in custom }
|
|
746
884
|
return [
|
|
747
885
|
kdef for kdef in [
|
|
748
886
|
Key.define(['RID'])
|
|
749
887
|
]
|
|
750
|
-
if ktup(kdef) not in
|
|
888
|
+
if ktup(kdef) not in customized
|
|
751
889
|
] + custom
|
|
752
890
|
|
|
753
891
|
@classmethod
|
|
754
|
-
def
|
|
892
|
+
def system_fkey_defs(cls, tname, custom=[]):
|
|
893
|
+
"""Build standard system fkey definitions, merging optional custom definitions."""
|
|
894
|
+
def fktup(fk):
|
|
895
|
+
return (
|
|
896
|
+
fk["referenced_columns"][0]["schema_name"],
|
|
897
|
+
fk["referenced_columns"][0]["table_name"],
|
|
898
|
+
frozenset(zip(
|
|
899
|
+
tuple( c["column_name"] for c in fk["foreign_key_columns"] ),
|
|
900
|
+
tuple( c["column_name"] for c in fk["referenced_columns"] ),
|
|
901
|
+
))
|
|
902
|
+
)
|
|
903
|
+
customized = { fktup(fkdef) for fkdef in custom }
|
|
904
|
+
return [
|
|
905
|
+
fkdef for fkdef in [
|
|
906
|
+
ForeignKey.define(
|
|
907
|
+
["RCB"], "public", "ERMrest_Client", ["ID"],
|
|
908
|
+
constraint_name=make_id(tname, "RCB", "fkey"),
|
|
909
|
+
),
|
|
910
|
+
ForeignKey.define(
|
|
911
|
+
["RMB"], "public", "ERMrest_Client", ["ID"],
|
|
912
|
+
constraint_name=make_id(tname, "RMB", "fkey"),
|
|
913
|
+
),
|
|
914
|
+
]
|
|
915
|
+
if fktup(fkdef) not in customized
|
|
916
|
+
] + custom
|
|
917
|
+
|
|
918
|
+
@classmethod
|
|
919
|
+
def _expand_references(
|
|
920
|
+
cls,
|
|
921
|
+
table_name: str,
|
|
922
|
+
column_defs: list[Key | Table | dict | tuple[str, bool, Key | Table] | tuple[str, Key | Table]],
|
|
923
|
+
fkey_defs: Iterable[dict],
|
|
924
|
+
used_names: set[str] =set(),
|
|
925
|
+
key_column_search_order: Iterable[str] | None = None,
|
|
926
|
+
):
|
|
927
|
+
"""Expand implicit references in column_defs into actual column and fkey definitions.
|
|
928
|
+
|
|
929
|
+
:param table_name: Name of table, needed to build fkey constraint names
|
|
930
|
+
:param column_defs: List of column definitions and/or reference targets (see below)
|
|
931
|
+
:param fkey_defs: List of foreign key definitions
|
|
932
|
+
:param used_names: Set of reference base names to consider already in use
|
|
933
|
+
|
|
934
|
+
Each reference target may be one of:
|
|
935
|
+
- Key
|
|
936
|
+
- Table
|
|
937
|
+
- tuple (str, Key|Table)
|
|
938
|
+
- tuple (str, bool, Key|Table)
|
|
939
|
+
|
|
940
|
+
A target Key specifies the columns of the remote table to
|
|
941
|
+
reference. A target Table instance specifies the remote table
|
|
942
|
+
and relies on heuristics to choose the target Key. A target
|
|
943
|
+
tuple specifies a string "base name" and optionally a boolean
|
|
944
|
+
"nullok" for the implied referencing columns. When omitted, a
|
|
945
|
+
default base name is constructed from target table
|
|
946
|
+
information, and a default nullok=False is specified for
|
|
947
|
+
referencing columns. The key_column_search_order parameter
|
|
948
|
+
influences the heuristic for selecting a target Key for an
|
|
949
|
+
input target Table.
|
|
950
|
+
|
|
951
|
+
This method mutates the input column_defs and used_names
|
|
952
|
+
containers. This can be abused to chain state through a
|
|
953
|
+
sequence of calls.
|
|
954
|
+
|
|
955
|
+
Returns the column_defs and fkey_defs which includes input
|
|
956
|
+
definitions and implied definitions while removing the corresponding input
|
|
957
|
+
reference targets.
|
|
958
|
+
|
|
959
|
+
"""
|
|
960
|
+
out_column_defs = []
|
|
961
|
+
out_fkey_defs = list(fkey_defs)
|
|
962
|
+
|
|
963
|
+
# materialize for mutation and replay
|
|
964
|
+
column_defs = list(column_defs)
|
|
965
|
+
|
|
966
|
+
if key_column_search_order is not None:
|
|
967
|
+
# materialize iterable for reuse
|
|
968
|
+
key_column_search_order = list(key_column_search_order)
|
|
969
|
+
else:
|
|
970
|
+
key_column_search_order = cls.default_key_column_search_order
|
|
971
|
+
|
|
972
|
+
def check_basename(basename):
|
|
973
|
+
if not isinstance(base_name, str):
|
|
974
|
+
raise TypeError('Base name %r is not of required type str' % (base_name,))
|
|
975
|
+
if base_name in used_names:
|
|
976
|
+
raise ValueError('Base name %r is not unique among inputs' % (base_name,))
|
|
977
|
+
used_names.add(base_name)
|
|
978
|
+
|
|
979
|
+
def choose_basename(key):
|
|
980
|
+
base_name = key.table.name
|
|
981
|
+
n = 2
|
|
982
|
+
while base_name in used_names or any(used.startswith(base_name) for used in used_names):
|
|
983
|
+
base_name = '%s%d' % (key.table.name, n)
|
|
984
|
+
n += 1
|
|
985
|
+
used_names.add(base_name)
|
|
986
|
+
return base_name
|
|
987
|
+
|
|
988
|
+
def check_key(key):
|
|
989
|
+
if isinstance(key, Table):
|
|
990
|
+
# opportunistic case: prefer (non-nullable) "name" or "id" keys, if found
|
|
991
|
+
for cname in key_column_search_order:
|
|
992
|
+
try:
|
|
993
|
+
candidate = key.key_by_columns([cname])
|
|
994
|
+
if not candidate.unique_columns[0].nullok:
|
|
995
|
+
return candidate
|
|
996
|
+
except (KeyError, ValueError) as e:
|
|
997
|
+
continue
|
|
998
|
+
|
|
999
|
+
# general case: try to use RID key
|
|
1000
|
+
try:
|
|
1001
|
+
return key.key_by_columns(["RID"])
|
|
1002
|
+
except (KeyError, ValueError) as e:
|
|
1003
|
+
raise ValueError('Could not determine default key for table %s' % (key,))
|
|
1004
|
+
elif isinstance(key, Key):
|
|
1005
|
+
return key
|
|
1006
|
+
raise TypeError('Expected Key or Table instance as target reference, not %s' % (key,))
|
|
1007
|
+
|
|
1008
|
+
# check and normalize cdefs into list[(str, Key)] with distinct base names
|
|
1009
|
+
for i in range(len(column_defs)):
|
|
1010
|
+
if isinstance(column_defs[i], tuple):
|
|
1011
|
+
if len(column_defs[i]) == 2:
|
|
1012
|
+
base_name, key = column_defs[i]
|
|
1013
|
+
nullok = False
|
|
1014
|
+
elif len(column_defs[i]) == 3:
|
|
1015
|
+
base_name, nullok, key = column_defs[i]
|
|
1016
|
+
else:
|
|
1017
|
+
raise ValueError('Expected column definition tuple (str, Key|Table) or (str, bool, Key|Table), not %s' % (len(column_defs[i]),))
|
|
1018
|
+
check_basename(base_name)
|
|
1019
|
+
key = check_key(key)
|
|
1020
|
+
column_defs[i] = (base_name, nullok, key)
|
|
1021
|
+
elif isinstance(column_defs[i], (Key, Table)):
|
|
1022
|
+
key = check_key(column_defs[i])
|
|
1023
|
+
base_name = choose_basename(key)
|
|
1024
|
+
column_defs[i] = (base_name, False, key)
|
|
1025
|
+
elif isinstance(column_defs[i], dict):
|
|
1026
|
+
pass
|
|
1027
|
+
else:
|
|
1028
|
+
raise TypeError('Expected column definition dict, Key, or Table input, not %s' % (column_defs[i],))
|
|
1029
|
+
|
|
1030
|
+
def simplify_type(ctype):
|
|
1031
|
+
if ctype.is_domain and ctype.typename.startswith('ermrest_'):
|
|
1032
|
+
return ctype.base_type
|
|
1033
|
+
return ctype
|
|
1034
|
+
|
|
1035
|
+
def cdefs_for_key(base_name, nullok, key):
|
|
1036
|
+
return [
|
|
1037
|
+
Column.define(
|
|
1038
|
+
'%s_%s' % (base_name, col.name) if len(key.unique_columns) > 1 else base_name,
|
|
1039
|
+
simplify_type(col.type),
|
|
1040
|
+
nullok=nullok,
|
|
1041
|
+
)
|
|
1042
|
+
for col in key.unique_columns
|
|
1043
|
+
]
|
|
1044
|
+
|
|
1045
|
+
def fkdef_for_key(base_name, nullok, key):
|
|
1046
|
+
return ForeignKey.define(
|
|
1047
|
+
[
|
|
1048
|
+
'%s_%s' % (base_name, col.name) if len(key.unique_columns) > 1 else base_name
|
|
1049
|
+
for col in key.unique_columns
|
|
1050
|
+
],
|
|
1051
|
+
key.table.schema.name,
|
|
1052
|
+
key.table.name,
|
|
1053
|
+
[ col.name for col in key.unique_columns ],
|
|
1054
|
+
on_update='CASCADE',
|
|
1055
|
+
on_delete='CASCADE',
|
|
1056
|
+
constraint_name=make_id(table_name, base_name, 'fkey'),
|
|
1057
|
+
)
|
|
1058
|
+
|
|
1059
|
+
for cdef in column_defs:
|
|
1060
|
+
if isinstance(cdef, tuple):
|
|
1061
|
+
out_column_defs.extend(cdefs_for_key(*cdef))
|
|
1062
|
+
out_fkey_defs.append(fkdef_for_key(*cdef))
|
|
1063
|
+
else:
|
|
1064
|
+
out_column_defs.append(cdef)
|
|
1065
|
+
|
|
1066
|
+
return out_column_defs, out_fkey_defs
|
|
1067
|
+
|
|
1068
|
+
@classmethod
|
|
1069
|
+
def define(
|
|
1070
|
+
cls,
|
|
1071
|
+
tname: str,
|
|
1072
|
+
column_defs: Iterable[dict | Key | Table | tuple[str, bool, Key | Table] | tuple[str, Key | Table]] = [],
|
|
1073
|
+
key_defs: Iterable[dict] = [],
|
|
1074
|
+
fkey_defs: Iterable[dict] = [],
|
|
1075
|
+
comment: str | None = None,
|
|
1076
|
+
acls: dict = {},
|
|
1077
|
+
acl_bindings: dict = {},
|
|
1078
|
+
annotations: dict = {},
|
|
1079
|
+
provide_system: bool = True,
|
|
1080
|
+
provide_system_fkeys: book = True,
|
|
1081
|
+
key_column_search_order: Iterable[str] | None = None,
|
|
1082
|
+
):
|
|
755
1083
|
"""Build a table definition.
|
|
756
1084
|
|
|
757
1085
|
:param tname: the name of the newly defined table
|
|
758
|
-
:param column_defs: a list of Column.define() results
|
|
1086
|
+
:param column_defs: a list of custom Column.define() results and/or reference targets (see below)
|
|
759
1087
|
:param key_defs: a list of Key.define() results for extra or overridden key constraint definitions
|
|
760
1088
|
:param fkey_defs: a list of ForeignKey.define() results for foreign key definitions
|
|
761
1089
|
:param comment: a comment string for the table
|
|
@@ -763,11 +1091,38 @@ class Table (object):
|
|
|
763
1091
|
:param acl_bindings: a dictionary of dynamic ACL bindings
|
|
764
1092
|
:param annotations: a dictionary of annotations
|
|
765
1093
|
:param provide_system: whether to inject standard system column definitions when missing from column_defs
|
|
1094
|
+
:param provide_system_fkeys: whether to also inject foreign key definitions for RCB/RMB
|
|
1095
|
+
:param key_column_search_order: override heuristic for choosing a Key from a Table input
|
|
1096
|
+
|
|
1097
|
+
Each reference target may be one of:
|
|
1098
|
+
- Key
|
|
1099
|
+
- Table
|
|
1100
|
+
- tuple (str, Key|Table)
|
|
1101
|
+
- tuple (str, bool, Key|Table)
|
|
1102
|
+
|
|
1103
|
+
A target Key specifies the columns of the remote table to
|
|
1104
|
+
reference. A target Table instance specifies the remote table
|
|
1105
|
+
and relies on heuristics to choose the target Key. A target
|
|
1106
|
+
tuple specifies a string "base name" and optionally a boolean
|
|
1107
|
+
"nullok" for the implied referencing columns. When omitted, a
|
|
1108
|
+
default base name is constructed from target table
|
|
1109
|
+
information, and a default nullok=False is specified for
|
|
1110
|
+
referencing columns. The key_column_search_order parameter
|
|
1111
|
+
influences the heuristic for selecting a target Key for an
|
|
1112
|
+
input target Table.
|
|
766
1113
|
|
|
767
1114
|
"""
|
|
1115
|
+
column_defs = list(column_defs) # materialize to allow replay
|
|
1116
|
+
used_names = { cdef["name"] for cdef in column_defs if isinstance(cdef, dict) and 'name' in cdef }
|
|
1117
|
+
column_defs, fkey_defs = cls._expand_references(tname, column_defs, fkey_defs, used_names, key_column_search_order)
|
|
1118
|
+
|
|
768
1119
|
if provide_system:
|
|
769
1120
|
column_defs = cls.system_column_defs(column_defs)
|
|
770
1121
|
key_defs = cls.system_key_defs(key_defs)
|
|
1122
|
+
if provide_system_fkeys:
|
|
1123
|
+
fkey_defs = cls.system_fkey_defs(tname, fkey_defs)
|
|
1124
|
+
else:
|
|
1125
|
+
key_defs = list(key_defs)
|
|
771
1126
|
|
|
772
1127
|
return {
|
|
773
1128
|
'table_name': tname,
|
|
@@ -781,13 +1136,29 @@ class Table (object):
|
|
|
781
1136
|
}
|
|
782
1137
|
|
|
783
1138
|
@classmethod
|
|
784
|
-
def define_vocabulary(
|
|
1139
|
+
def define_vocabulary(
|
|
1140
|
+
cls,
|
|
1141
|
+
tname: str,
|
|
1142
|
+
curie_template: str,
|
|
1143
|
+
uri_template: str = '/id/{RID}',
|
|
1144
|
+
column_defs: Iterable[dict | Key | Table | tuple[str, bool, Key | Table] | tuple[str, Key | Table]] = [],
|
|
1145
|
+
key_defs=[],
|
|
1146
|
+
fkey_defs=[],
|
|
1147
|
+
comment: str | None = None,
|
|
1148
|
+
acls: dict = {},
|
|
1149
|
+
acl_bindings: dict = {},
|
|
1150
|
+
annotations: dict = {},
|
|
1151
|
+
provide_system: bool = True,
|
|
1152
|
+
provide_system_fkeys: bool = True,
|
|
1153
|
+
provide_name_key: bool = True,
|
|
1154
|
+
key_column_search_order: Iterable[str] | None = None,
|
|
1155
|
+
):
|
|
785
1156
|
"""Build a vocabulary table definition.
|
|
786
1157
|
|
|
787
1158
|
:param tname: the name of the newly defined table
|
|
788
1159
|
:param curie_template: the RID-based template for the CURIE of locally-defined terms, e.g. 'MYPROJECT:{RID}'
|
|
789
1160
|
:param uri_template: the RID-based template for the URI of locally-defined terms, e.g. 'https://server.example.org/id/{RID}'
|
|
790
|
-
:param column_defs: a list of Column.define() results
|
|
1161
|
+
:param column_defs: a list of Column.define() results and/or reference targets (see below)
|
|
791
1162
|
:param key_defs: a list of Key.define() results for extra or overridden key constraint definitions
|
|
792
1163
|
:param fkey_defs: a list of ForeignKey.define() results for foreign key definitions
|
|
793
1164
|
:param comment: a comment string for the table
|
|
@@ -795,6 +1166,9 @@ class Table (object):
|
|
|
795
1166
|
:param acl_bindings: a dictionary of dynamic ACL bindings
|
|
796
1167
|
:param annotations: a dictionary of annotations
|
|
797
1168
|
:param provide_system: whether to inject standard system column definitions when missing from column_defs
|
|
1169
|
+
:param provide_system_fkeys: whether to also inject foreign key definitions for RCB/RMB
|
|
1170
|
+
:param provide_name_key: whether to inject a key definition for the Name column
|
|
1171
|
+
:param key_column_search_order: override heuristic for choosing a Key from a Table input
|
|
798
1172
|
|
|
799
1173
|
These core vocabulary columns are generated automatically if
|
|
800
1174
|
absent from the input column_defs.
|
|
@@ -805,7 +1179,10 @@ class Table (object):
|
|
|
805
1179
|
- Description: markdown, not null
|
|
806
1180
|
- Synonyms: text[]
|
|
807
1181
|
|
|
808
|
-
However, caller-supplied definitions override the default.
|
|
1182
|
+
However, caller-supplied definitions override the default. See
|
|
1183
|
+
Table.define() documentation for an explanation reference
|
|
1184
|
+
targets in the column_defs list and the related
|
|
1185
|
+
key_column_search_order parameter.
|
|
809
1186
|
|
|
810
1187
|
"""
|
|
811
1188
|
if not re.match("^[A-Za-z][-_A-Za-z0-9]*:[{]RID[}]$", curie_template):
|
|
@@ -855,41 +1232,52 @@ class Table (object):
|
|
|
855
1232
|
|
|
856
1233
|
def add_vocab_keys(custom):
|
|
857
1234
|
def ktup(k):
|
|
858
|
-
return
|
|
1235
|
+
return frozenset(k['unique_columns'])
|
|
859
1236
|
return [
|
|
860
1237
|
key_def
|
|
861
1238
|
for key_def in [
|
|
862
1239
|
Key.define(['ID']),
|
|
863
1240
|
Key.define(['URI']),
|
|
864
|
-
]
|
|
1241
|
+
] + ([ Key.define(['Name']) ] if provide_name_key else [])
|
|
865
1242
|
if ktup(key_def) not in { ktup(kdef): kdef for kdef in custom }
|
|
866
1243
|
] + custom
|
|
867
1244
|
|
|
1245
|
+
used_names = {'ID', 'URI', 'Name', 'Description', 'Synonyms'}
|
|
1246
|
+
column_defs, fkey_defs = cls._expand_references(tname, column_defs, fkey_defs, used_names, key_column_search_order)
|
|
1247
|
+
column_defs = add_vocab_columns(column_defs)
|
|
1248
|
+
key_defs = add_vocab_keys(key_defs)
|
|
1249
|
+
|
|
868
1250
|
return cls.define(
|
|
869
1251
|
tname,
|
|
870
|
-
|
|
871
|
-
|
|
1252
|
+
column_defs,
|
|
1253
|
+
key_defs,
|
|
872
1254
|
fkey_defs,
|
|
873
1255
|
comment,
|
|
874
1256
|
acls,
|
|
875
1257
|
acl_bindings,
|
|
876
1258
|
annotations,
|
|
877
|
-
provide_system
|
|
1259
|
+
provide_system=provide_system,
|
|
1260
|
+
provide_system_fkeys=provide_system_fkeys,
|
|
1261
|
+
key_column_search_order=key_column_search_order,
|
|
878
1262
|
)
|
|
879
1263
|
|
|
880
1264
|
@classmethod
|
|
881
|
-
def define_asset(
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
1265
|
+
def define_asset(
|
|
1266
|
+
cls,
|
|
1267
|
+
sname: str,
|
|
1268
|
+
tname: str,
|
|
1269
|
+
hatrac_template=None,
|
|
1270
|
+
column_defs: Iterable[dict | Key | Table | tuple[str, bool, Key | Table] | tuple[str, Key | Table]] = [],
|
|
1271
|
+
key_defs=[],
|
|
1272
|
+
fkey_defs=[],
|
|
1273
|
+
comment: str | None = None,
|
|
1274
|
+
acls: dict = {},
|
|
1275
|
+
acl_bindings: dict = {},
|
|
1276
|
+
annotations: dict = {},
|
|
1277
|
+
provide_system: bool = True,
|
|
1278
|
+
provide_system_fkeys: bool = True,
|
|
1279
|
+
key_column_search_order: Iterable[str] | None = None,
|
|
1280
|
+
):
|
|
893
1281
|
"""Build an asset table definition.
|
|
894
1282
|
|
|
895
1283
|
:param sname: the name of the schema for the asset table
|
|
@@ -899,7 +1287,7 @@ class Table (object):
|
|
|
899
1287
|
/hatrac/schema_name/table_name/md5.filename
|
|
900
1288
|
where the filename and md5 value is computed on upload and the schema_name and table_name are the
|
|
901
1289
|
values of the provided arguments. If value is set to False, no hatrac_template is used.
|
|
902
|
-
:param column_defs: a list of Column.define() results
|
|
1290
|
+
:param column_defs: a list of Column.define() results and/or reference targets (see below)
|
|
903
1291
|
:param key_defs: a list of Key.define() results for extra or overridden key constraint definitions
|
|
904
1292
|
:param fkey_defs: a list of ForeignKey.define() results for foreign key definitions
|
|
905
1293
|
:param comment: a comment string for the table
|
|
@@ -907,22 +1295,28 @@ class Table (object):
|
|
|
907
1295
|
:param acl_bindings: a dictionary of dynamic ACL bindings
|
|
908
1296
|
:param annotations: a dictionary of annotations
|
|
909
1297
|
:param provide_system: whether to inject standard system column definitions when missing from column_defs
|
|
1298
|
+
:param provide_system_fkeys: whether to also inject foreign key definitions for RCB/RMB
|
|
1299
|
+
:param key_column_search_order: override heuristic for choosing a Key from a Table input
|
|
910
1300
|
|
|
911
1301
|
These core asset table columns are generated automatically if
|
|
912
1302
|
absent from the input column_defs.
|
|
913
1303
|
|
|
914
1304
|
- Filename: ermrest_curie, unique not null, default curie template "%s:{RID}" % curie_prefix
|
|
915
1305
|
- URL: Location of the asset, unique not null. Default template is:
|
|
916
|
-
/hatrac/sname/tname/{{{MD5}}}.{{{Filename}}} where tname is the name of the asset table.
|
|
1306
|
+
/hatrac/cat_id/sname/tname/{{{MD5}}}.{{{Filename}}} where tname is the name of the asset table.
|
|
917
1307
|
- Length: Length of the asset.
|
|
918
1308
|
- MD5: text
|
|
919
1309
|
- Description: markdown, not null
|
|
920
1310
|
|
|
921
|
-
However, caller-supplied definitions override the
|
|
1311
|
+
However, caller-supplied definitions override the
|
|
1312
|
+
default. See Table.define() documentation for an explanation
|
|
1313
|
+
reference targets in the column_defs list and the related
|
|
1314
|
+
key_column_search_order parameter.
|
|
922
1315
|
|
|
923
1316
|
In addition to creating the columns, this function also creates an asset annotation on the URL column to
|
|
924
1317
|
facilitate use of the table by Chaise.
|
|
925
|
-
|
|
1318
|
+
|
|
1319
|
+
"""
|
|
926
1320
|
|
|
927
1321
|
if hatrac_template is None:
|
|
928
1322
|
hatrac_template = '/hatrac/{{$catalog.id}}/%s/%s/{{{MD5}}}.{{#encode}}{{{Filename}}}{{/encode}}' % (sname, tname)
|
|
@@ -956,7 +1350,7 @@ class Table (object):
|
|
|
956
1350
|
|
|
957
1351
|
def add_asset_keys(custom):
|
|
958
1352
|
def ktup(k):
|
|
959
|
-
return
|
|
1353
|
+
return frozenset(k['unique_columns'])
|
|
960
1354
|
|
|
961
1355
|
return [
|
|
962
1356
|
key_def
|
|
@@ -977,6 +1371,9 @@ class Table (object):
|
|
|
977
1371
|
asset_annotations.update(custom)
|
|
978
1372
|
return asset_annotations
|
|
979
1373
|
|
|
1374
|
+
used_names = {'URL', 'Filename', 'Description', 'Length', 'MD5'}
|
|
1375
|
+
column_defs, fkey_defs = cls._expand_references(tname, column_defs, fkey_defs, used_names, key_column_search_order)
|
|
1376
|
+
|
|
980
1377
|
return cls.define(
|
|
981
1378
|
tname,
|
|
982
1379
|
add_asset_columns(column_defs),
|
|
@@ -986,29 +1383,49 @@ class Table (object):
|
|
|
986
1383
|
acls,
|
|
987
1384
|
acl_bindings,
|
|
988
1385
|
add_asset_annotations(annotations),
|
|
989
|
-
provide_system
|
|
1386
|
+
provide_system=provide_system,
|
|
1387
|
+
provide_system_fkeys=provide_system_fkeys,
|
|
1388
|
+
key_column_search_order=key_column_search_order,
|
|
990
1389
|
)
|
|
991
1390
|
|
|
992
1391
|
@classmethod
|
|
993
|
-
def define_page(
|
|
1392
|
+
def define_page(
|
|
1393
|
+
cls,
|
|
1394
|
+
tname,
|
|
1395
|
+
column_defs: Iterable[dict | Key | Table | tuple[str, bool, Key | Table] | tuple[str, Key | Table]] = [],
|
|
1396
|
+
key_defs=[],
|
|
1397
|
+
fkey_defs=[],
|
|
1398
|
+
comment: str | None = None,
|
|
1399
|
+
acls: dict = {},
|
|
1400
|
+
acl_bindings: dict = {},
|
|
1401
|
+
annotations: dict = {},
|
|
1402
|
+
provide_system: bool = True,
|
|
1403
|
+
provide_system_fkeys: bool = True,
|
|
1404
|
+
key_column_search_order: Iterable[str] | None = None,
|
|
1405
|
+
):
|
|
994
1406
|
"""Build a wiki-like "page" table definition.
|
|
995
1407
|
|
|
996
1408
|
:param tname: the name of the newly defined table
|
|
997
1409
|
:param column_defs: a list of Column.define() results for extra or overridden column definitions
|
|
998
|
-
:param key_defs: a list of Key.define() results
|
|
1410
|
+
:param key_defs: a list of Key.define() results and/or reference targets (see below)
|
|
999
1411
|
:param fkey_defs: a list of ForeignKey.define() results for foreign key definitions
|
|
1000
1412
|
:param comment: a comment string for the table
|
|
1001
1413
|
:param acls: a dictionary of ACLs for specific access modes
|
|
1002
1414
|
:param acl_bindings: a dictionary of dynamic ACL bindings
|
|
1003
1415
|
:param annotations: a dictionary of annotations
|
|
1004
1416
|
:param provide_system: whether to inject standard system column definitions when missing from column_defs
|
|
1417
|
+
:param provide_system_fkeys: whether to also inject foreign key definitions for RCB/RMB
|
|
1418
|
+
:param key_column_search_order: override heuristic for choosing a Key from a Table input
|
|
1005
1419
|
|
|
1006
1420
|
These core page columns are generated automatically if absent from the input column_defs.
|
|
1007
1421
|
|
|
1008
1422
|
- Title: text, unique not null
|
|
1009
1423
|
- Content: markdown
|
|
1010
1424
|
|
|
1011
|
-
However, caller-supplied definitions override the default.
|
|
1425
|
+
However, caller-supplied definitions override the default. See
|
|
1426
|
+
Table.define() documentation for an explanation reference
|
|
1427
|
+
targets in the column_defs list and the related
|
|
1428
|
+
key_column_search_order parameter.
|
|
1012
1429
|
"""
|
|
1013
1430
|
|
|
1014
1431
|
def add_page_columns(custom):
|
|
@@ -1033,7 +1450,7 @@ class Table (object):
|
|
|
1033
1450
|
|
|
1034
1451
|
def add_page_keys(custom):
|
|
1035
1452
|
def ktup(k):
|
|
1036
|
-
return
|
|
1453
|
+
return frozenset(k['unique_columns'])
|
|
1037
1454
|
return [
|
|
1038
1455
|
key_def
|
|
1039
1456
|
for key_def in [
|
|
@@ -1063,6 +1480,9 @@ class Table (object):
|
|
|
1063
1480
|
page_annotations.update(annotations)
|
|
1064
1481
|
return page_annotations
|
|
1065
1482
|
|
|
1483
|
+
used_names = {'Title', 'Content'}
|
|
1484
|
+
column_defs, fkey_defs = cls._expand_references(tname, column_defs, fkey_defs, used_names, key_column_search_order)
|
|
1485
|
+
|
|
1066
1486
|
return cls.define(
|
|
1067
1487
|
tname,
|
|
1068
1488
|
add_page_columns(column_defs),
|
|
@@ -1072,7 +1492,129 @@ class Table (object):
|
|
|
1072
1492
|
acls,
|
|
1073
1493
|
acl_bindings,
|
|
1074
1494
|
add_page_annotations(annotations),
|
|
1075
|
-
provide_system
|
|
1495
|
+
provide_system=provide_system,
|
|
1496
|
+
provide_system_fkeys=provide_system_fkeys,
|
|
1497
|
+
key_column_search_order=key_column_search_order,
|
|
1498
|
+
)
|
|
1499
|
+
|
|
1500
|
+
@classmethod
|
|
1501
|
+
def define_association(
|
|
1502
|
+
cls,
|
|
1503
|
+
associates: Iterable[Key | Table | tuple[str, Key | Table]],
|
|
1504
|
+
metadata: Iterable[Key | Table | dict | tuple[str, bool, Key | Table]] = [],
|
|
1505
|
+
table_name: str | None = None,
|
|
1506
|
+
comment: str | None = None,
|
|
1507
|
+
provide_system: bool = True,
|
|
1508
|
+
provide_system_fkeys: bool = True,
|
|
1509
|
+
key_column_search_order: Iterable[str] | None = None,
|
|
1510
|
+
) -> dict:
|
|
1511
|
+
"""Build an association table definition.
|
|
1512
|
+
|
|
1513
|
+
:param associates: reference targets being associated (see below)
|
|
1514
|
+
:param metadata: additional metadata fields and/or reference targets for impure associations
|
|
1515
|
+
:param table_name: name for the association table or None for default naming
|
|
1516
|
+
:param comment: comment for the association table or None for default comment
|
|
1517
|
+
:param provide_system: add ERMrest system columns when True
|
|
1518
|
+
:param provide_system_fkeys: whether to also inject foreign key definitions for RCB/RMB
|
|
1519
|
+
:param key_column_search_order: override heuristic for choosing a Key from a Table input
|
|
1520
|
+
|
|
1521
|
+
This is a utility function to help build an association table
|
|
1522
|
+
definition. It simplifies the task, but removes some
|
|
1523
|
+
control. For full customization, consider using Table.define()
|
|
1524
|
+
directly instead.
|
|
1525
|
+
|
|
1526
|
+
A normal ("pure") N-ary association is a table with N foreign
|
|
1527
|
+
keys referencing N primary keys in referenced tables, with a
|
|
1528
|
+
composite primary key covering the N foreign keys. These pure
|
|
1529
|
+
association tables manage a set of distinct combinations of
|
|
1530
|
+
the associated foreign key values.
|
|
1531
|
+
|
|
1532
|
+
An "impure" association table adds additional metadata
|
|
1533
|
+
alongside the N foreign keys.
|
|
1534
|
+
|
|
1535
|
+
The "associates" parameter takes an iterable of reference
|
|
1536
|
+
targets. The association will be comprised of foreign keys
|
|
1537
|
+
referencing these associates. This includes columns to store
|
|
1538
|
+
the associated foreign key values, foreign key constraints to
|
|
1539
|
+
the associated tables, and a composite key constraint
|
|
1540
|
+
covering all the associated foreign key values.
|
|
1541
|
+
|
|
1542
|
+
The "metadata" parameter takes an iterable Column.define()
|
|
1543
|
+
results and/or reference targets. The association table will
|
|
1544
|
+
be augmented with extra metadata columns and foreign keys as
|
|
1545
|
+
directed by these inputs.
|
|
1546
|
+
|
|
1547
|
+
See the Table.define() method documentation for more on
|
|
1548
|
+
reference targets. Association columns must be defined with
|
|
1549
|
+
nullok=False, so the associates parameter is restricted to a
|
|
1550
|
+
more limited form of reference target input without
|
|
1551
|
+
caller-controlled nullok boolean values.
|
|
1552
|
+
|
|
1553
|
+
"""
|
|
1554
|
+
associates = list(associates)
|
|
1555
|
+
metadata = list(metadata)
|
|
1556
|
+
|
|
1557
|
+
if key_column_search_order is not None:
|
|
1558
|
+
# materialize iterable for reuse
|
|
1559
|
+
key_column_search_order = list(key_column_search_order)
|
|
1560
|
+
else:
|
|
1561
|
+
key_column_search_order = cls.default_key_column_search_order
|
|
1562
|
+
|
|
1563
|
+
if len(associates) < 2:
|
|
1564
|
+
raise ValueError('An association table requires at least 2 associates')
|
|
1565
|
+
|
|
1566
|
+
used_names = set()
|
|
1567
|
+
|
|
1568
|
+
for assoc in associates:
|
|
1569
|
+
if not isinstance(assoc, (tuple, Table, Key)):
|
|
1570
|
+
raise TypeError("Associates must be Table or Key instances, not %s" % (assoc,))
|
|
1571
|
+
|
|
1572
|
+
# first pass: build "pure" association table parts
|
|
1573
|
+
# HACK: use dummy table name if we don't have one yet
|
|
1574
|
+
tname = table_name if table_name is not None else "dummy"
|
|
1575
|
+
cdefs, fkdefs = cls._expand_references(tname, associates, [], used_names)
|
|
1576
|
+
|
|
1577
|
+
if table_name is None:
|
|
1578
|
+
# use first pass results to build table_name
|
|
1579
|
+
def get_assoc_name(assoc):
|
|
1580
|
+
if isinstance(assoc, tuple):
|
|
1581
|
+
table = assoc[1]
|
|
1582
|
+
elif isinstance(assoc, Key):
|
|
1583
|
+
table = key.table
|
|
1584
|
+
elif isinstance(assoc, Table):
|
|
1585
|
+
table = assoc
|
|
1586
|
+
else:
|
|
1587
|
+
raise ValueError("expected (str, Key|Table) | Key | Table, not %s" % (assoc,))
|
|
1588
|
+
return table.name
|
|
1589
|
+
table_name = make_id(*[ get_assoc_name(assoc) for assoc in associates ])
|
|
1590
|
+
# HACK: repeat first pass to make proper fkey def constraint names
|
|
1591
|
+
used_names = set()
|
|
1592
|
+
cdefs, fkdefs = cls._expand_references(table_name, associates, [], used_names)
|
|
1593
|
+
|
|
1594
|
+
# build assoc key from union of associates' foreign key columns
|
|
1595
|
+
k_cnames = []
|
|
1596
|
+
for fkdef in fkdefs:
|
|
1597
|
+
k_cnames.extend([ colref["column_name"] for colref in fkdef["foreign_key_columns"] ])
|
|
1598
|
+
|
|
1599
|
+
kdefs = [
|
|
1600
|
+
Key.define(
|
|
1601
|
+
k_cnames,
|
|
1602
|
+
constraint_name=make_id(table_name, 'assoc', 'key'),
|
|
1603
|
+
)
|
|
1604
|
+
]
|
|
1605
|
+
|
|
1606
|
+
# run second pass to expand out metadata targets
|
|
1607
|
+
cdefs, fkdefs = cls._expand_references(table_name, cdefs + metadata, fkdefs, used_names)
|
|
1608
|
+
|
|
1609
|
+
return Table.define(
|
|
1610
|
+
table_name,
|
|
1611
|
+
cdefs,
|
|
1612
|
+
kdefs,
|
|
1613
|
+
fkdefs,
|
|
1614
|
+
comment=comment,
|
|
1615
|
+
provide_system=provide_system,
|
|
1616
|
+
provide_system_fkeys=provide_system_fkeys,
|
|
1617
|
+
key_column_search_order=key_column_search_order,
|
|
1076
1618
|
)
|
|
1077
1619
|
|
|
1078
1620
|
def prejson(self, prune=True):
|
|
@@ -1155,7 +1697,8 @@ class Table (object):
|
|
|
1155
1697
|
comment=nochange,
|
|
1156
1698
|
acls=nochange,
|
|
1157
1699
|
acl_bindings=nochange,
|
|
1158
|
-
annotations=nochange
|
|
1700
|
+
annotations=nochange,
|
|
1701
|
+
update_mappings=UpdateMappings.no_update
|
|
1159
1702
|
):
|
|
1160
1703
|
"""Alter existing schema definition.
|
|
1161
1704
|
|
|
@@ -1165,6 +1708,7 @@ class Table (object):
|
|
|
1165
1708
|
:param acls: Replacement ACL configuration (default nochange)
|
|
1166
1709
|
:param acl_bindings: Replacement ACL bindings (default nochange)
|
|
1167
1710
|
:param annotations: Replacement annotations (default nochange)
|
|
1711
|
+
:param update_mappings: Update annotations to reflect changes (default UpdateMappings.no_updates)
|
|
1168
1712
|
|
|
1169
1713
|
A change of schema name is a transfer of the existing table to
|
|
1170
1714
|
an existing destination schema (not a rename of the current
|
|
@@ -1192,14 +1736,25 @@ class Table (object):
|
|
|
1192
1736
|
self.schema.tables[self.name] = self
|
|
1193
1737
|
|
|
1194
1738
|
if 'schema_name' in changes:
|
|
1739
|
+
old_schema_name = self.schema.name
|
|
1195
1740
|
del self.schema.tables[self.name]
|
|
1196
1741
|
self.schema = self.schema.model.schemas[changed['schema_name']]
|
|
1742
|
+
for key in self.keys:
|
|
1743
|
+
if key.constraint_schema:
|
|
1744
|
+
key.constraint_schema = self.schema
|
|
1197
1745
|
for fkey in self.foreign_keys:
|
|
1198
1746
|
if fkey.constraint_schema:
|
|
1199
1747
|
del fkey.constraint_schema._fkeys[fkey.constraint_name]
|
|
1200
1748
|
fkey.constraint_schema = self.schema
|
|
1201
1749
|
fkey.constraint_schema._fkeys[fkey.constraint_name] = fkey
|
|
1202
1750
|
self.schema.tables[self.name] = self
|
|
1751
|
+
if update_mappings:
|
|
1752
|
+
for key in self.keys:
|
|
1753
|
+
mmo.replace(self.schema.model, [old_schema_name] + [key.constraint_name], [self.schema.name] + [key.constraint_name])
|
|
1754
|
+
for fkey in self.foreign_keys:
|
|
1755
|
+
mmo.replace(self.schema.model, [old_schema_name] + [fkey.constraint_name], [self.schema.name] + [fkey.constraint_name])
|
|
1756
|
+
if update_mappings == UpdateMappings.immediate:
|
|
1757
|
+
self.schema.model.apply()
|
|
1203
1758
|
|
|
1204
1759
|
if 'comment' in changes:
|
|
1205
1760
|
self.comment = changed['comment']
|
|
@@ -1240,7 +1795,7 @@ class Table (object):
|
|
|
1240
1795
|
created = created[0]
|
|
1241
1796
|
return registerfunc(constructor(self, created))
|
|
1242
1797
|
|
|
1243
|
-
def create_column(self, column_def):
|
|
1798
|
+
def create_column(self, column_def: dict) -> Column:
|
|
1244
1799
|
"""Add a new column to this table in the remote database based on column_def.
|
|
1245
1800
|
|
|
1246
1801
|
Returns a new Column instance based on the server-supplied
|
|
@@ -1255,7 +1810,7 @@ class Table (object):
|
|
|
1255
1810
|
return col
|
|
1256
1811
|
return self._create_table_part('column', add_column, Column, column_def)
|
|
1257
1812
|
|
|
1258
|
-
def create_key(self, key_def):
|
|
1813
|
+
def create_key(self, key_def: dict) -> Key:
|
|
1259
1814
|
"""Add a new key to this table in the remote database based on key_def.
|
|
1260
1815
|
|
|
1261
1816
|
Returns a new Key instance based on the server-supplied
|
|
@@ -1268,12 +1823,12 @@ class Table (object):
|
|
|
1268
1823
|
return key
|
|
1269
1824
|
return self._create_table_part('key', add_key, Key, key_def)
|
|
1270
1825
|
|
|
1271
|
-
def create_fkey(self, fkey_def):
|
|
1826
|
+
def create_fkey(self, fkey_def: dict) -> ForeignKey:
|
|
1272
1827
|
"""Add a new foreign key to this table in the remote database based on fkey_def.
|
|
1273
1828
|
|
|
1274
1829
|
Returns a new ForeignKey instance based on the
|
|
1275
1830
|
server-supplied representation of the new foreign key, and
|
|
1276
|
-
adds it to self.
|
|
1831
|
+
adds it to self.foreign_keys too.
|
|
1277
1832
|
|
|
1278
1833
|
"""
|
|
1279
1834
|
def add_fkey(fkey):
|
|
@@ -1282,16 +1837,53 @@ class Table (object):
|
|
|
1282
1837
|
return fkey
|
|
1283
1838
|
return self._create_table_part('foreignkey', add_fkey, ForeignKey, fkey_def)
|
|
1284
1839
|
|
|
1285
|
-
def
|
|
1840
|
+
def create_reference(
|
|
1841
|
+
self,
|
|
1842
|
+
target: Key | Table | tuple[str, bool, Key | Table] | tuple[str, Key | Table],
|
|
1843
|
+
key_column_search_order: Iterable[str] | None = None,
|
|
1844
|
+
) -> tuple[ list[Column], ForeignKey ]:
|
|
1845
|
+
"""Add column(s) and a foreign key to this table in the remote database for a reference target.
|
|
1846
|
+
|
|
1847
|
+
See Table.define() documentation for more about reference
|
|
1848
|
+
targets.
|
|
1849
|
+
|
|
1850
|
+
Returns a list of new Column instances and a ForeignKey instance based on
|
|
1851
|
+
the server-supplied representation of the new model elements, and
|
|
1852
|
+
adds them to the self.columns and self.foreign_keys too.
|
|
1853
|
+
|
|
1854
|
+
"""
|
|
1855
|
+
used_names = { col.name for col in self.columns }
|
|
1856
|
+
cdefs, fkdefs = self._expand_references(self.name, [ target ], [], used_names, key_column_search_order)
|
|
1857
|
+
if not cdefs or len(fkdefs) != 1:
|
|
1858
|
+
raise NotImplementedError("BUG? got unexpected results from self._expand_reference()")
|
|
1859
|
+
cols = [ self.create_column(cdef) for cdef in cdefs ]
|
|
1860
|
+
fkeys = [ self.create_fkey(fkdef) for fkdef in fkdefs ]
|
|
1861
|
+
return cols, fkeys[0]
|
|
1862
|
+
|
|
1863
|
+
def drop(self, cascade=False, update_mappings=UpdateMappings.no_update):
|
|
1286
1864
|
"""Remove this table from the remote database.
|
|
1865
|
+
|
|
1866
|
+
:param cascade: drop dependent objects.
|
|
1867
|
+
:param update_mappings: update annotations to reflect changes (default False)
|
|
1287
1868
|
"""
|
|
1288
1869
|
if self.name not in self.schema.tables:
|
|
1289
1870
|
raise ValueError('Table %s does not appear to belong to schema %s.' % (self, self.schema))
|
|
1871
|
+
|
|
1872
|
+
if cascade:
|
|
1873
|
+
for fkey in list(self.referenced_by):
|
|
1874
|
+
fkey.drop(update_mappings=update_mappings)
|
|
1875
|
+
|
|
1290
1876
|
self.catalog.delete(self.uri_path).raise_for_status()
|
|
1291
1877
|
del self.schema.tables[self.name]
|
|
1292
1878
|
for fkey in self.foreign_keys:
|
|
1293
1879
|
fkey._cleanup()
|
|
1294
1880
|
|
|
1881
|
+
if update_mappings:
|
|
1882
|
+
for fkey in self.foreign_keys:
|
|
1883
|
+
mmo.prune(self.schema.model, [fkey.constraint_schema.name, fkey.constraint_name])
|
|
1884
|
+
if update_mappings == UpdateMappings.immediate:
|
|
1885
|
+
self.schema.model.apply()
|
|
1886
|
+
|
|
1295
1887
|
def key_by_columns(self, unique_columns, raise_nomatch=True):
|
|
1296
1888
|
"""Return key from self.keys with matching unique columns.
|
|
1297
1889
|
|
|
@@ -1348,7 +1940,7 @@ class Table (object):
|
|
|
1348
1940
|
if raise_nomatch:
|
|
1349
1941
|
raise KeyError(from_to_map)
|
|
1350
1942
|
|
|
1351
|
-
def is_association(self, min_arity=2, max_arity=2, unqualified=True, pure=True, no_overlap=True):
|
|
1943
|
+
def is_association(self, min_arity=2, max_arity=2, unqualified=True, pure=True, no_overlap=True, return_fkeys=False):
|
|
1352
1944
|
"""Return (truthy) integer arity if self is a matching association, else False.
|
|
1353
1945
|
|
|
1354
1946
|
min_arity: minimum number of associated fkeys (default 2)
|
|
@@ -1356,6 +1948,7 @@ class Table (object):
|
|
|
1356
1948
|
unqualified: reject qualified associations when True (default True)
|
|
1357
1949
|
pure: reject impure assocations when True (default True)
|
|
1358
1950
|
no_overlap: reject overlapping associations when True (default True)
|
|
1951
|
+
return_fkeys: return the set of N associated ForeignKeys if True
|
|
1359
1952
|
|
|
1360
1953
|
The default behavior with no arguments is to test for pure,
|
|
1361
1954
|
unqualified, non-overlapping, binary assocations.
|
|
@@ -1444,8 +2037,74 @@ class Table (object):
|
|
|
1444
2037
|
# reject: impure association
|
|
1445
2038
|
return False
|
|
1446
2039
|
|
|
1447
|
-
# return (truthy) arity
|
|
1448
|
-
|
|
2040
|
+
# return (truthy) arity or fkeys
|
|
2041
|
+
if return_fkeys:
|
|
2042
|
+
return covered_fkeys
|
|
2043
|
+
else:
|
|
2044
|
+
return len(covered_fkeys)
|
|
2045
|
+
|
|
2046
|
+
def find_associations(self, min_arity=2, max_arity=2, unqualified=True, pure=True, no_overlap=True) -> Iterable[FindAssociationResult]:
|
|
2047
|
+
"""Yield (iterable) Association objects linking to this table and meeting all criteria.
|
|
2048
|
+
|
|
2049
|
+
min_arity: minimum number of associated fkeys (default 2)
|
|
2050
|
+
max_arity: maximum number of associated fkeys (default 2) or None
|
|
2051
|
+
unqualified: reject qualified associations when True (default True)
|
|
2052
|
+
pure: reject impure assocations when True (default True)
|
|
2053
|
+
no_overlap: reject overlapping associations when True (default True)
|
|
2054
|
+
|
|
2055
|
+
See documentation for sibling method Table.is_association(...)
|
|
2056
|
+
for more explanation of these association detection criteria.
|
|
2057
|
+
|
|
2058
|
+
"""
|
|
2059
|
+
peer_tables = set()
|
|
2060
|
+
for fkey in self.referenced_by:
|
|
2061
|
+
peer = fkey.table
|
|
2062
|
+
if peer in peer_tables:
|
|
2063
|
+
# check each peer only once
|
|
2064
|
+
continue
|
|
2065
|
+
peer_tables.add(peer)
|
|
2066
|
+
answer = peer.is_association(min_arity=min_arity, max_arity=max_arity, unqualified=unqualified, pure=pure, no_overlap=no_overlap, return_fkeys=True)
|
|
2067
|
+
if answer:
|
|
2068
|
+
answer = set(answer)
|
|
2069
|
+
for fkey in answer:
|
|
2070
|
+
if fkey.pk_table == self:
|
|
2071
|
+
answer.remove(fkey)
|
|
2072
|
+
yield FindAssociationResult(peer, fkey, answer)
|
|
2073
|
+
# arbitrarily choose first fkey to self
|
|
2074
|
+
# in case association is back to same table
|
|
2075
|
+
break
|
|
2076
|
+
|
|
2077
|
+
def sqlite3_table_name(self) -> str:
|
|
2078
|
+
"""Return SQLite3 mapped table name for this table"""
|
|
2079
|
+
return "%s:%s" % (
|
|
2080
|
+
self.schema.name,
|
|
2081
|
+
self.name,
|
|
2082
|
+
)
|
|
2083
|
+
|
|
2084
|
+
def sqlite3_ddl(self, keys: bool=True) -> str:
|
|
2085
|
+
"""Return SQLite3 table definition DDL statement for this table.
|
|
2086
|
+
|
|
2087
|
+
:param keys: If true, include unique constraints for each table key
|
|
2088
|
+
|
|
2089
|
+
Caveat: this utility does not produce:
|
|
2090
|
+
- column default expressions
|
|
2091
|
+
- foreign key constraint DDL
|
|
2092
|
+
|
|
2093
|
+
Both of these features are fragile in data export scenarios
|
|
2094
|
+
where we want to represent arbitrary ERMrest catalog dumps.
|
|
2095
|
+
|
|
2096
|
+
"""
|
|
2097
|
+
parts = [ col.sqlite3_ddl() for col in self.columns ]
|
|
2098
|
+
if keys:
|
|
2099
|
+
parts.extend([ key.sqlite3_ddl() for key in self.keys ])
|
|
2100
|
+
return ("""
|
|
2101
|
+
CREATE TABLE IF NOT EXISTS %(tname)s
|
|
2102
|
+
%(body)s
|
|
2103
|
+
);
|
|
2104
|
+
""" % {
|
|
2105
|
+
'tname': sql_identifier(self.sqlite3_table_name()),
|
|
2106
|
+
'body': ',\n '.join(parts),
|
|
2107
|
+
})
|
|
1449
2108
|
|
|
1450
2109
|
@presence_annotation(tag.immutable)
|
|
1451
2110
|
def immutable(self): pass
|
|
@@ -1495,6 +2154,40 @@ class Table (object):
|
|
|
1495
2154
|
@object_annotation(tag.viz_3d_display)
|
|
1496
2155
|
def viz_3d_display(self): pass
|
|
1497
2156
|
|
|
2157
|
+
class Quantifier (str, Enum):
|
|
2158
|
+
"""Logic quantifiers"""
|
|
2159
|
+
any = 'any'
|
|
2160
|
+
all = 'all'
|
|
2161
|
+
|
|
2162
|
+
def find_tables_with_foreign_keys(target_tables: Iterable[Table], quantifier: Quantifier=Quantifier.all) -> set[Table]:
|
|
2163
|
+
"""Return set of tables with foreign key references to target tables.
|
|
2164
|
+
|
|
2165
|
+
:param target_tables: an iterable of ermrest_model.Table instances
|
|
2166
|
+
:param quantifier: one of the Quantifiers 'any' or 'all' (default 'all')
|
|
2167
|
+
|
|
2168
|
+
Each returned Table instance will be a table that references the
|
|
2169
|
+
targets according to the selected quantifier. A reference is a
|
|
2170
|
+
direct foreign key in the returned table that refers to a primary
|
|
2171
|
+
key of the target table.
|
|
2172
|
+
|
|
2173
|
+
- quantifier==all: a returned table references ALL targets
|
|
2174
|
+
- quantifier==any: a returned table references AT LEAST ONE target
|
|
2175
|
+
|
|
2176
|
+
For proper function, all target_tables instances MUST come from
|
|
2177
|
+
the same root Model instance hierarchy.
|
|
2178
|
+
|
|
2179
|
+
"""
|
|
2180
|
+
candidates = None
|
|
2181
|
+
for table in target_tables:
|
|
2182
|
+
referring = { fkey.table for fkey in table.referenced_by }
|
|
2183
|
+
if candidates is None:
|
|
2184
|
+
candidates = referring
|
|
2185
|
+
elif quantifier == Quantifier.all:
|
|
2186
|
+
candidates.intersection_update(referring)
|
|
2187
|
+
else:
|
|
2188
|
+
candidates.update(referring)
|
|
2189
|
+
return candidates
|
|
2190
|
+
|
|
1498
2191
|
class Column (object):
|
|
1499
2192
|
"""Named column.
|
|
1500
2193
|
"""
|
|
@@ -1510,6 +2203,17 @@ class Column (object):
|
|
|
1510
2203
|
self.default = column_doc.get('default')
|
|
1511
2204
|
self.comment = column_doc.get('comment')
|
|
1512
2205
|
|
|
2206
|
+
def __repr__(self):
|
|
2207
|
+
cls = type(self)
|
|
2208
|
+
return "<%s.%s object %r.%r.%r at 0x%x>" % (
|
|
2209
|
+
cls.__module__,
|
|
2210
|
+
cls.__name__,
|
|
2211
|
+
self.table.schema.name if self.table is not None and self.table.schema is not None else None,
|
|
2212
|
+
self.table.name if self.table is not None else None,
|
|
2213
|
+
self.name,
|
|
2214
|
+
id(self),
|
|
2215
|
+
)
|
|
2216
|
+
|
|
1513
2217
|
@property
|
|
1514
2218
|
def catalog(self):
|
|
1515
2219
|
return self.table.schema.model.catalog
|
|
@@ -1604,7 +2308,8 @@ class Column (object):
|
|
|
1604
2308
|
comment=nochange,
|
|
1605
2309
|
acls=nochange,
|
|
1606
2310
|
acl_bindings=nochange,
|
|
1607
|
-
annotations=nochange
|
|
2311
|
+
annotations=nochange,
|
|
2312
|
+
update_mappings=UpdateMappings.no_update
|
|
1608
2313
|
):
|
|
1609
2314
|
"""Alter existing schema definition.
|
|
1610
2315
|
|
|
@@ -1616,6 +2321,7 @@ class Column (object):
|
|
|
1616
2321
|
:param acls: Replacement ACL configuration (default nochange)
|
|
1617
2322
|
:param acl_bindings: Replacement ACL bindings (default nochange)
|
|
1618
2323
|
:param annotations: Replacement annotations (default nochange)
|
|
2324
|
+
:param update_mappings: Update annotations to reflect changes (default UpdateMappings.no_updates)
|
|
1619
2325
|
|
|
1620
2326
|
Returns self (to allow for optional chained access).
|
|
1621
2327
|
|
|
@@ -1642,8 +2348,14 @@ class Column (object):
|
|
|
1642
2348
|
|
|
1643
2349
|
if 'name' in changes:
|
|
1644
2350
|
del self.table.column_definitions.elements[self.name]
|
|
2351
|
+
oldname = self.name
|
|
1645
2352
|
self.name = changed['name']
|
|
1646
2353
|
self.table.column_definitions.elements[self.name] = self
|
|
2354
|
+
if update_mappings:
|
|
2355
|
+
basename = [self.table.schema.name, self.table.name]
|
|
2356
|
+
mmo.replace(self.table.schema.model, basename + [oldname], basename + [self.name])
|
|
2357
|
+
if update_mappings == UpdateMappings.immediate:
|
|
2358
|
+
self.table.schema.model.apply()
|
|
1647
2359
|
|
|
1648
2360
|
if 'type' in changes:
|
|
1649
2361
|
self.type = make_type(changed['type'])
|
|
@@ -1671,14 +2383,41 @@ class Column (object):
|
|
|
1671
2383
|
|
|
1672
2384
|
return self
|
|
1673
2385
|
|
|
1674
|
-
def drop(self):
|
|
2386
|
+
def drop(self, cascade=False, update_mappings=UpdateMappings.no_update):
|
|
1675
2387
|
"""Remove this column from the remote database.
|
|
2388
|
+
|
|
2389
|
+
:param cascade: drop dependent objects (default False)
|
|
2390
|
+
:param update_mappings: Update annotations to reflect changes (default UpdateMappings.no_updates)
|
|
1676
2391
|
"""
|
|
1677
2392
|
if self.name not in self.table.column_definitions.elements:
|
|
1678
2393
|
raise ValueError('Column %s does not appear to belong to table %s.' % (self, self.table))
|
|
2394
|
+
|
|
2395
|
+
if cascade:
|
|
2396
|
+
for fkey in list(self.table.foreign_keys):
|
|
2397
|
+
if self in fkey.foreign_key_columns:
|
|
2398
|
+
fkey.drop(update_mappings=update_mappings)
|
|
2399
|
+
for key in list(self.table.keys):
|
|
2400
|
+
if self in key.unique_columns:
|
|
2401
|
+
key.drop(cascade=True, update_mappings=update_mappings)
|
|
2402
|
+
|
|
1679
2403
|
self.catalog.delete(self.uri_path).raise_for_status()
|
|
1680
2404
|
del self.table.column_definitions[self.name]
|
|
1681
2405
|
|
|
2406
|
+
if update_mappings:
|
|
2407
|
+
mmo.prune(self.table.schema.model, [self.table.schema.name, self.table.name, self.name])
|
|
2408
|
+
if update_mappings == UpdateMappings.immediate:
|
|
2409
|
+
self.table.schema.model.apply()
|
|
2410
|
+
|
|
2411
|
+
def sqlite3_ddl(self) -> str:
|
|
2412
|
+
"""Return SQLite3 column definition DDL fragment for this column."""
|
|
2413
|
+
parts = [
|
|
2414
|
+
sql_identifier(self.name),
|
|
2415
|
+
self.type.sqlite3_ddl(),
|
|
2416
|
+
]
|
|
2417
|
+
if not self.nullok:
|
|
2418
|
+
parts.append('NOT NULL')
|
|
2419
|
+
return ' '.join(parts)
|
|
2420
|
+
|
|
1682
2421
|
@presence_annotation(tag.immutable)
|
|
1683
2422
|
def immutable(self): pass
|
|
1684
2423
|
|
|
@@ -1696,7 +2435,6 @@ class Column (object):
|
|
|
1696
2435
|
|
|
1697
2436
|
@object_annotation(tag.column_display)
|
|
1698
2437
|
def column_display(self): pass
|
|
1699
|
-
|
|
1700
2438
|
|
|
1701
2439
|
def _constraint_name_parts(constraint, doc):
|
|
1702
2440
|
# modern systems should have 0 or 1 names here
|
|
@@ -1707,6 +2445,19 @@ def _constraint_name_parts(constraint, doc):
|
|
|
1707
2445
|
constraint_schema = None
|
|
1708
2446
|
elif names[0][0] == constraint.table.schema.name:
|
|
1709
2447
|
constraint_schema = constraint.table.schema
|
|
2448
|
+
elif names[0][0] == 'placeholder':
|
|
2449
|
+
# mitigate ermrest API response bug reflecting our 'placeholder' value
|
|
2450
|
+
if constraint.table.kind == 'table':
|
|
2451
|
+
if isinstance(constraint, Key):
|
|
2452
|
+
constraint_schema = constraint.table.schema
|
|
2453
|
+
elif isinstance(constraint, ForeignKey):
|
|
2454
|
+
# HACK: mostly correct for regular ermrest users
|
|
2455
|
+
# may be revised later during fkey digest for irregular cases with SQL views!
|
|
2456
|
+
constraint_schema = constraint.table.schema
|
|
2457
|
+
else:
|
|
2458
|
+
raise TypeError('_constraint_name_parts requires a Key or ForeignKey constraint argument, not %s' % (constraint,))
|
|
2459
|
+
else:
|
|
2460
|
+
constraint_schema = None
|
|
1710
2461
|
else:
|
|
1711
2462
|
raise ValueError('Unexpected schema name in constraint %s' % (names[0],))
|
|
1712
2463
|
constraint_name = names[0][1]
|
|
@@ -1728,6 +2479,16 @@ class Key (object):
|
|
|
1728
2479
|
for cname in key_doc['unique_columns']
|
|
1729
2480
|
])
|
|
1730
2481
|
|
|
2482
|
+
def __repr__(self):
|
|
2483
|
+
cls = type(self)
|
|
2484
|
+
return "<%s.%s object %r.%r at 0x%x>" % (
|
|
2485
|
+
cls.__module__,
|
|
2486
|
+
cls.__name__,
|
|
2487
|
+
self.constraint_schema.name if self.constraint_schema is not None else None,
|
|
2488
|
+
self.constraint_name,
|
|
2489
|
+
id(self),
|
|
2490
|
+
)
|
|
2491
|
+
|
|
1731
2492
|
@property
|
|
1732
2493
|
def columns(self):
|
|
1733
2494
|
"""Sugared access to self.unique_columns"""
|
|
@@ -1781,10 +2542,29 @@ class Key (object):
|
|
|
1781
2542
|
}
|
|
1782
2543
|
|
|
1783
2544
|
@classmethod
|
|
1784
|
-
def define(cls, colnames, constraint_names=[], comment=None, annotations={}):
|
|
1785
|
-
"""Build a key definition.
|
|
2545
|
+
def define(cls, colnames, constraint_names=[], comment=None, annotations={}, constraint_name=None):
|
|
2546
|
+
"""Build a key definition.
|
|
2547
|
+
|
|
2548
|
+
:param colnames: List of names of columns participating in the key
|
|
2549
|
+
:param constraint_names: Legacy input [ [ schema_name, constraint_name ] ] (for API backwards-compatibility)
|
|
2550
|
+
:param comment: Comment string
|
|
2551
|
+
:param annotations: Dictionary of { annotation_uri: annotation_value, ... }
|
|
2552
|
+
:param constraint_name: Constraint name string
|
|
2553
|
+
|
|
2554
|
+
The constraint_name kwarg takes a bare constraint name string
|
|
2555
|
+
and acts the same as setting the legacy constraint_names kwarg
|
|
2556
|
+
to: [ [ "placeholder", constraint_name ] ]. This odd syntax
|
|
2557
|
+
is for backwards-compatibility with earlier API versions, and
|
|
2558
|
+
mirrors the structure of constraint names in ERMrest model
|
|
2559
|
+
description outputs. In those outputs, the "placeholder" field
|
|
2560
|
+
contains the schema name of the table containing the
|
|
2561
|
+
constraint.
|
|
2562
|
+
|
|
2563
|
+
"""
|
|
1786
2564
|
if not isinstance(colnames, list):
|
|
1787
2565
|
raise TypeError('Colnames should be a list.')
|
|
2566
|
+
if constraint_name is not None:
|
|
2567
|
+
constraint_names = [ [ "placeholder", constraint_name ] ]
|
|
1788
2568
|
return {
|
|
1789
2569
|
'unique_columns': list(colnames),
|
|
1790
2570
|
'names': constraint_names,
|
|
@@ -1826,13 +2606,15 @@ class Key (object):
|
|
|
1826
2606
|
self,
|
|
1827
2607
|
constraint_name=nochange,
|
|
1828
2608
|
comment=nochange,
|
|
1829
|
-
annotations=nochange
|
|
2609
|
+
annotations=nochange,
|
|
2610
|
+
update_mappings=UpdateMappings.no_update
|
|
1830
2611
|
):
|
|
1831
2612
|
"""Alter existing schema definition.
|
|
1832
2613
|
|
|
1833
2614
|
:param constraint_name: Unqualified constraint name string
|
|
1834
2615
|
:param comment: Replacement comment (default nochange)
|
|
1835
2616
|
:param annotations: Replacement annotations (default nochange)
|
|
2617
|
+
:param update_mappings: Update annotations to reflect changes (default UpdateMappings.no_updates)
|
|
1836
2618
|
|
|
1837
2619
|
Returns self (to allow for optional chained access).
|
|
1838
2620
|
|
|
@@ -1852,7 +2634,13 @@ class Key (object):
|
|
|
1852
2634
|
changed = r.json() # use changed vs changes to get server-digested values
|
|
1853
2635
|
|
|
1854
2636
|
if 'names' in changes:
|
|
2637
|
+
oldname = self.constraint_name
|
|
1855
2638
|
self.constraint_name = changed['names'][0][1]
|
|
2639
|
+
if update_mappings:
|
|
2640
|
+
basename = [self.table.schema.name]
|
|
2641
|
+
mmo.replace(self.table.schema.model, basename + [oldname], basename + [self.constraint_name])
|
|
2642
|
+
if update_mappings == UpdateMappings.immediate:
|
|
2643
|
+
self.table.schema.model.apply()
|
|
1856
2644
|
|
|
1857
2645
|
if 'comment' in changes:
|
|
1858
2646
|
self.comment = changed['comment']
|
|
@@ -1863,14 +2651,34 @@ class Key (object):
|
|
|
1863
2651
|
|
|
1864
2652
|
return self
|
|
1865
2653
|
|
|
1866
|
-
def drop(self):
|
|
2654
|
+
def drop(self, cascade=False, update_mappings=UpdateMappings.no_update):
|
|
1867
2655
|
"""Remove this key from the remote database.
|
|
2656
|
+
|
|
2657
|
+
:param cascade: drop dependent objects (default False)
|
|
2658
|
+
:param update_mappings: Update annotations to reflect changes (default UpdateMappings.no_updates)
|
|
1868
2659
|
"""
|
|
1869
2660
|
if self.name not in self.table.keys.elements:
|
|
1870
2661
|
raise ValueError('Key %s does not appear to belong to table %s.' % (self, self.table))
|
|
2662
|
+
|
|
2663
|
+
if cascade:
|
|
2664
|
+
for fkey in list(self.table.referenced_by):
|
|
2665
|
+
assert self.table == fkey.pk_table, "Expected key.table and foreign_key.pk_table to match"
|
|
2666
|
+
if set(self.unique_columns) == set(fkey.referenced_columns):
|
|
2667
|
+
fkey.drop(update_mappings=update_mappings)
|
|
2668
|
+
|
|
1871
2669
|
self.catalog.delete(self.uri_path).raise_for_status()
|
|
1872
2670
|
del self.table.keys[self.name]
|
|
1873
2671
|
|
|
2672
|
+
if update_mappings:
|
|
2673
|
+
mmo.prune(self.table.schema.model, [self.constraint_schema.name, self.constraint_name])
|
|
2674
|
+
if update_mappings == UpdateMappings.immediate:
|
|
2675
|
+
self.table.schema.model.apply()
|
|
2676
|
+
|
|
2677
|
+
def sqlite3_ddl(self) -> str:
|
|
2678
|
+
"""Return SQLite3 unique constraint DDL fragment for this key."""
|
|
2679
|
+
parts = [ sql_identifier(col.name) for col in self.unique_columns ]
|
|
2680
|
+
return 'UNIQUE (%s)' % (', '.join(parts),)
|
|
2681
|
+
|
|
1874
2682
|
class ForeignKey (object):
|
|
1875
2683
|
"""Named foreign key.
|
|
1876
2684
|
"""
|
|
@@ -1898,6 +2706,16 @@ class ForeignKey (object):
|
|
|
1898
2706
|
self._referenced_columns_doc = fkey_doc['referenced_columns']
|
|
1899
2707
|
self.referenced_columns = None
|
|
1900
2708
|
|
|
2709
|
+
def __repr__(self):
|
|
2710
|
+
cls = type(self)
|
|
2711
|
+
return "<%s.%s object %r.%r at 0x%x>" % (
|
|
2712
|
+
cls.__module__,
|
|
2713
|
+
cls.__name__,
|
|
2714
|
+
self.constraint_schema.name if self.constraint_schema is not None else None,
|
|
2715
|
+
self.constraint_name,
|
|
2716
|
+
id(self),
|
|
2717
|
+
)
|
|
2718
|
+
|
|
1901
2719
|
def digest_referenced_columns(self, model):
|
|
1902
2720
|
"""Finish construction deferred until model is known with all tables."""
|
|
1903
2721
|
if self.referenced_columns is None:
|
|
@@ -1910,6 +2728,13 @@ class ForeignKey (object):
|
|
|
1910
2728
|
])
|
|
1911
2729
|
self._referenced_columns_doc = None
|
|
1912
2730
|
self.pk_table.referenced_by.append(self)
|
|
2731
|
+
# HACK: clean up schema qualification for psuedo constraint
|
|
2732
|
+
# this may happen only with SQL views in the ermrest catalog
|
|
2733
|
+
if self.pk_table.kind != 'table' and self.constraint_name in self.table.schema._fkeys:
|
|
2734
|
+
del self.table.schema._fkeys[self.constraint_name]
|
|
2735
|
+
self.table.schema.model._fkeys[self.constraint_name] = self
|
|
2736
|
+
del self.table.foreign_keys.elements[(self.table.schema, self.constraint_name)]
|
|
2737
|
+
self.table.foreign_keys.elements[(None, self.constraint_name)] = self
|
|
1913
2738
|
|
|
1914
2739
|
@property
|
|
1915
2740
|
def column_map(self):
|
|
@@ -1983,9 +2808,41 @@ class ForeignKey (object):
|
|
|
1983
2808
|
}
|
|
1984
2809
|
|
|
1985
2810
|
@classmethod
|
|
1986
|
-
def define(cls, fk_colnames, pk_sname, pk_tname, pk_colnames, on_update='NO ACTION', on_delete='NO ACTION', constraint_names=[], comment=None, acls={}, acl_bindings={}, annotations={}):
|
|
2811
|
+
def define(cls, fk_colnames, pk_sname, pk_tname, pk_colnames, on_update='NO ACTION', on_delete='NO ACTION', constraint_names=[], comment=None, acls={}, acl_bindings={}, annotations={}, constraint_name=None):
|
|
2812
|
+
"""Define a foreign key.
|
|
2813
|
+
|
|
2814
|
+
:param fk_colnames: List of column names participating in the foreign key
|
|
2815
|
+
:param pk_sname: Schema name string of the referenced primary key
|
|
2816
|
+
:param pk_tname: Table name string of the referenced primary key
|
|
2817
|
+
:param pk_colnames: List of column names participating in the referenced primary key
|
|
2818
|
+
:param on_update: Constraint behavior when referenced primary keys are updated
|
|
2819
|
+
:param on_update: Constraint behavior when referenced primary keys are deleted
|
|
2820
|
+
:param constraint_names: Legacy input [ [ schema_name, constraint_name ] ] (for API backwards-compatibility)
|
|
2821
|
+
:param comment: Comment string
|
|
2822
|
+
:param acls: Dictionary of { acl_name: acl, ... }
|
|
2823
|
+
:param acl_bindings: Dictionary of { binding_name: acl_binding, ... }
|
|
2824
|
+
:param annotations: Dictionary of { annotation_uri: annotation_value, ... }
|
|
2825
|
+
:param constraint_name: Constraint name string
|
|
2826
|
+
|
|
2827
|
+
The contraint behavior values for on_update and on_delete must
|
|
2828
|
+
be one of the following literal strings:
|
|
2829
|
+
|
|
2830
|
+
'NO ACTION', 'RESTRICT', 'CASCADE', 'SET NULL', 'SET DEFAULT'
|
|
2831
|
+
|
|
2832
|
+
The constraint_name kwarg takes a bare constraint name string
|
|
2833
|
+
and acts the same as setting the legacy constraint_names kwarg
|
|
2834
|
+
to: [ [ "placeholder", constraint_name ] ]. This odd syntax
|
|
2835
|
+
is for backwards-compatibility with earlier API versions, and
|
|
2836
|
+
mirrors the structure of constraint names in ERMrest model
|
|
2837
|
+
description outputs. In those outputs, the "placeholder" field
|
|
2838
|
+
contains the schema name of the table containing the
|
|
2839
|
+
constraint.
|
|
2840
|
+
|
|
2841
|
+
"""
|
|
1987
2842
|
if len(fk_colnames) != len(pk_colnames):
|
|
1988
2843
|
raise ValueError('The fk_colnames and pk_colnames lists must have the same length.')
|
|
2844
|
+
if constraint_name is not None:
|
|
2845
|
+
constraint_names = [ [ "placeholder", constraint_name ], ]
|
|
1989
2846
|
return {
|
|
1990
2847
|
'foreign_key_columns': [
|
|
1991
2848
|
{
|
|
@@ -2057,7 +2914,8 @@ class ForeignKey (object):
|
|
|
2057
2914
|
comment=nochange,
|
|
2058
2915
|
acls=nochange,
|
|
2059
2916
|
acl_bindings=nochange,
|
|
2060
|
-
annotations=nochange
|
|
2917
|
+
annotations=nochange,
|
|
2918
|
+
update_mappings=UpdateMappings.no_update
|
|
2061
2919
|
):
|
|
2062
2920
|
"""Alter existing schema definition.
|
|
2063
2921
|
|
|
@@ -2068,6 +2926,7 @@ class ForeignKey (object):
|
|
|
2068
2926
|
:param acls: Replacement ACL configuration (default nochange)
|
|
2069
2927
|
:param acl_bindings: Replacement ACL bindings (default nochange)
|
|
2070
2928
|
:param annotations: Replacement annotations (default nochange)
|
|
2929
|
+
:param update_mappings: Update annotations to reflect changes (default UpdateMappings.no_updates)
|
|
2071
2930
|
|
|
2072
2931
|
Returns self (to allow for optional chained access).
|
|
2073
2932
|
|
|
@@ -2095,11 +2954,17 @@ class ForeignKey (object):
|
|
|
2095
2954
|
del self.constraint_schema._fkeys[self.constraint_name]
|
|
2096
2955
|
else:
|
|
2097
2956
|
del self.table.schema.model._pseudo_fkeys[self.constraint_name]
|
|
2957
|
+
oldname = self.constraint_name
|
|
2098
2958
|
self.constraint_name = changed['names'][0][1]
|
|
2099
2959
|
if self.constraint_schema:
|
|
2100
2960
|
self.constraint_schema._fkeys[self.constraint_name] = self
|
|
2101
2961
|
else:
|
|
2102
2962
|
self.table.schema.model._pseudo_fkeys[self.constraint_name] = self
|
|
2963
|
+
if update_mappings:
|
|
2964
|
+
basename = [self.table.schema.name]
|
|
2965
|
+
mmo.replace(self.table.schema.model, basename + [oldname], basename + [self.constraint_name])
|
|
2966
|
+
if update_mappings == UpdateMappings.immediate:
|
|
2967
|
+
self.table.schema.model.apply()
|
|
2103
2968
|
|
|
2104
2969
|
if 'on_update' in changes:
|
|
2105
2970
|
self.on_update = changed['on_update']
|
|
@@ -2124,8 +2989,10 @@ class ForeignKey (object):
|
|
|
2124
2989
|
|
|
2125
2990
|
return self
|
|
2126
2991
|
|
|
2127
|
-
def drop(self):
|
|
2992
|
+
def drop(self, update_mappings=UpdateMappings.no_update):
|
|
2128
2993
|
"""Remove this foreign key from the remote database.
|
|
2994
|
+
|
|
2995
|
+
:param update_mappings: Update annotations to reflect changes (default UpdateMappings.no_updates)
|
|
2129
2996
|
"""
|
|
2130
2997
|
if self.name not in self.table.foreign_keys.elements:
|
|
2131
2998
|
raise ValueError('Foreign key %s does not appear to belong to table %s.' % (self, self.table))
|
|
@@ -2133,6 +3000,11 @@ class ForeignKey (object):
|
|
|
2133
3000
|
del self.table.foreign_keys[self.name]
|
|
2134
3001
|
self._cleanup()
|
|
2135
3002
|
|
|
3003
|
+
if update_mappings:
|
|
3004
|
+
mmo.prune(self.table.schema.model, [self.constraint_schema.name, self.constraint_name])
|
|
3005
|
+
if update_mappings == UpdateMappings.immediate:
|
|
3006
|
+
self.table.schema.model.apply()
|
|
3007
|
+
|
|
2136
3008
|
def _cleanup(self):
|
|
2137
3009
|
"""Cleanup references in the local model following drop from remote database.
|
|
2138
3010
|
"""
|
|
@@ -2168,6 +3040,22 @@ class Type (object):
|
|
|
2168
3040
|
}
|
|
2169
3041
|
return d
|
|
2170
3042
|
|
|
3043
|
+
def sqlite3_ddl(self) -> str:
|
|
3044
|
+
"""Return a SQLite3 column type DDL fragment for this type"""
|
|
3045
|
+
return {
|
|
3046
|
+
'boolean': 'boolean',
|
|
3047
|
+
'date': 'date',
|
|
3048
|
+
'float4': 'real',
|
|
3049
|
+
'float8': 'real',
|
|
3050
|
+
'int2': 'integer',
|
|
3051
|
+
'int4': 'integer',
|
|
3052
|
+
'int8': 'integer',
|
|
3053
|
+
'json': 'json',
|
|
3054
|
+
'jsonb': 'json',
|
|
3055
|
+
'timestamptz': 'datetime',
|
|
3056
|
+
'timestamp': 'datetime',
|
|
3057
|
+
}.get(self.typename, 'text')
|
|
3058
|
+
|
|
2171
3059
|
class DomainType (Type):
|
|
2172
3060
|
"""Named domain type.
|
|
2173
3061
|
"""
|
|
@@ -2184,6 +3072,10 @@ class DomainType (Type):
|
|
|
2184
3072
|
})
|
|
2185
3073
|
return d
|
|
2186
3074
|
|
|
3075
|
+
def sqlite3_ddl(self) -> str:
|
|
3076
|
+
"""Return a SQLite3 column type DDL fragment for this type"""
|
|
3077
|
+
return self.base_type.sqlite3_ddl()
|
|
3078
|
+
|
|
2187
3079
|
class ArrayType (Type):
|
|
2188
3080
|
"""Named domain type.
|
|
2189
3081
|
"""
|
|
@@ -2200,6 +3092,10 @@ class ArrayType (Type):
|
|
|
2200
3092
|
})
|
|
2201
3093
|
return d
|
|
2202
3094
|
|
|
3095
|
+
def sqlite3_ddl(self) -> str:
|
|
3096
|
+
"""Return a SQLite3 column type DDL fragment for this type"""
|
|
3097
|
+
return 'json'
|
|
3098
|
+
|
|
2203
3099
|
builtin_types = AttrDict(
|
|
2204
3100
|
# first define standard scalar types
|
|
2205
3101
|
{
|