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.
@@ -1,9 +1,16 @@
1
1
 
2
- from collections import OrderedDict
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 tuple(k['unique_columns'])
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 { ktup(kdef): kdef for kdef in custom }
888
+ if ktup(kdef) not in customized
751
889
  ] + custom
752
890
 
753
891
  @classmethod
754
- def define(cls, tname, column_defs=[], key_defs=[], fkey_defs=[], comment=None, acls={}, acl_bindings={}, annotations={}, provide_system=True):
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 for extra or overridden column definitions
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(cls, tname, curie_template, uri_template='/id/{RID}', column_defs=[], key_defs=[], fkey_defs=[], comment=None, acls={}, acl_bindings={}, annotations={}, provide_system=True):
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 for extra or overridden column definitions
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 tuple(k['unique_columns'])
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
- add_vocab_columns(column_defs),
871
- add_vocab_keys(key_defs),
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(cls,
882
- sname,
883
- tname,
884
- hatrac_template=None,
885
- column_defs=[],
886
- key_defs=[],
887
- fkey_defs=[],
888
- comment=None,
889
- acls={},
890
- acl_bindings={},
891
- annotations={},
892
- provide_system=True):
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 for extra or overridden column definitions
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 default.
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 tuple(k['unique_columns'])
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(cls, tname, column_defs=[], key_defs=[], fkey_defs=[], comment=None, acls={}, acl_bindings={}, annotations={}, provide_system=True):
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 for extra or overridden key constraint definitions
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 tuple(k['unique_columns'])
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.fkeys too.
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 drop(self):
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
- return len(covered_fkeys)
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
  {