deriva 1.7.3__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,15 +1,16 @@
1
1
 
2
2
  from __future__ import annotations
3
3
 
4
+ import base64
5
+ import hashlib
6
+ import json
7
+ import re
4
8
  from collections import OrderedDict
5
9
  from collections.abc import Iterable
6
10
  from enum import Enum
7
- import json
8
- import re
9
- import base64
10
- import hashlib
11
11
 
12
- from . import AttrDict, tag, urlquote, stob
12
+ from . import AttrDict, tag, urlquote, stob, mmo
13
+
13
14
 
14
15
  class NoChange (object):
15
16
  """Special class used to distinguish no-change default arguments to methods.
@@ -23,6 +24,12 @@ class NoChange (object):
23
24
  # singletone to use in APIs below
24
25
  nochange = NoChange()
25
26
 
27
+ class UpdateMappings (str, Enum):
28
+ """Update Mappings flag enum"""
29
+ no_update = ''
30
+ deferred = 'deferred'
31
+ immediate = 'immediate'
32
+
26
33
  def make_id(*components):
27
34
  """Build an identifier that will be OK for ERMrest and Postgres.
28
35
 
@@ -91,6 +98,19 @@ def make_id(*components):
91
98
  # last-ditch (e.g. multibyte unicode suffix worst case)
92
99
  return truncate(naive_result, 55) + naive_hash
93
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
+
94
114
  def presence_annotation(tag_uri):
95
115
  """Decorator to establish property getter/setter/deleter for presence annotations.
96
116
 
@@ -521,6 +541,15 @@ class Schema (object):
521
541
  for tname, tdoc in schema_doc.get('tables', {}).items()
522
542
  }
523
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
+
524
553
  @property
525
554
  def catalog(self):
526
555
  return self.model.catalog
@@ -625,16 +654,16 @@ class Schema (object):
625
654
  for tname, table in self.tables.items():
626
655
  table.apply(existing.tables[tname] if existing else None)
627
656
 
628
- 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):
629
658
  """Alter existing schema definition.
630
659
 
631
660
  :param schema_name: Replacement schema name (default nochange)
632
661
  :param comment: Replacement comment (default nochange)
633
662
  :param acls: Replacement ACL configuration (default nochange)
634
663
  :param annotations: Replacement annotations (default nochange)
664
+ :param update_mappings: Update annotations to reflect changes (default UpdateMappings.no_updates)
635
665
 
636
666
  Returns self (to allow for optional chained access).
637
-
638
667
  """
639
668
  changes = strip_nochange({
640
669
  'schema_name': schema_name,
@@ -648,9 +677,14 @@ class Schema (object):
648
677
  changed = r.json() # use changed vs changes to get server-digested values
649
678
 
650
679
  if 'schema_name' in changes:
680
+ old_schema_name = self.name
651
681
  del self.model.schemas[self.name]
652
682
  self.name = changed['schema_name']
653
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()
654
688
 
655
689
  if 'comment' in changes:
656
690
  self.comment = changed['comment']
@@ -686,11 +720,19 @@ class Schema (object):
686
720
  self.model.digest_fkeys()
687
721
  return newtable
688
722
 
689
- def drop(self):
723
+ def drop(self, cascade=False, update_mappings=UpdateMappings.no_update):
690
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)
691
728
  """
692
729
  if self.name not in self.model.schemas:
693
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
+
694
736
  self.catalog.delete(self.uri_path).raise_for_status()
695
737
  del self.model.schemas[self.name]
696
738
  for table in self.tables.values():
@@ -770,6 +812,8 @@ class FindAssociationResult (object):
770
812
  class Table (object):
771
813
  """Named table.
772
814
  """
815
+ default_key_column_search_order = ["Name", "name", "ID", "id"]
816
+
773
817
  def __init__(self, schema, tname, table_doc):
774
818
  self.schema = schema
775
819
  self.name = tname
@@ -792,6 +836,16 @@ class Table (object):
792
836
  ])
793
837
  self.referenced_by = KeyedList([])
794
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
+
795
849
  @property
796
850
  def columns(self):
797
851
  """Sugared access to self.column_definitions"""
@@ -825,20 +879,211 @@ class Table (object):
825
879
  def system_key_defs(cls, custom=[]):
826
880
  """Build standard system key definitions, merging optional custom definitions."""
827
881
  def ktup(k):
828
- return tuple(k['unique_columns'])
882
+ return frozenset(k['unique_columns'])
883
+ customized = { ktup(kdef): kdef for kdef in custom }
829
884
  return [
830
885
  kdef for kdef in [
831
886
  Key.define(['RID'])
832
887
  ]
833
- if ktup(kdef) not in { ktup(kdef): kdef for kdef in custom }
888
+ if ktup(kdef) not in customized
834
889
  ] + custom
835
890
 
836
891
  @classmethod
837
- 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
+ ):
838
1083
  """Build a table definition.
839
1084
 
840
1085
  :param tname: the name of the newly defined table
841
- :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)
842
1087
  :param key_defs: a list of Key.define() results for extra or overridden key constraint definitions
843
1088
  :param fkey_defs: a list of ForeignKey.define() results for foreign key definitions
844
1089
  :param comment: a comment string for the table
@@ -846,11 +1091,38 @@ class Table (object):
846
1091
  :param acl_bindings: a dictionary of dynamic ACL bindings
847
1092
  :param annotations: a dictionary of annotations
848
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.
849
1113
 
850
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
+
851
1119
  if provide_system:
852
1120
  column_defs = cls.system_column_defs(column_defs)
853
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)
854
1126
 
855
1127
  return {
856
1128
  'table_name': tname,
@@ -864,13 +1136,29 @@ class Table (object):
864
1136
  }
865
1137
 
866
1138
  @classmethod
867
- 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
+ ):
868
1156
  """Build a vocabulary table definition.
869
1157
 
870
1158
  :param tname: the name of the newly defined table
871
1159
  :param curie_template: the RID-based template for the CURIE of locally-defined terms, e.g. 'MYPROJECT:{RID}'
872
1160
  :param uri_template: the RID-based template for the URI of locally-defined terms, e.g. 'https://server.example.org/id/{RID}'
873
- :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)
874
1162
  :param key_defs: a list of Key.define() results for extra or overridden key constraint definitions
875
1163
  :param fkey_defs: a list of ForeignKey.define() results for foreign key definitions
876
1164
  :param comment: a comment string for the table
@@ -878,6 +1166,9 @@ class Table (object):
878
1166
  :param acl_bindings: a dictionary of dynamic ACL bindings
879
1167
  :param annotations: a dictionary of annotations
880
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
881
1172
 
882
1173
  These core vocabulary columns are generated automatically if
883
1174
  absent from the input column_defs.
@@ -888,7 +1179,10 @@ class Table (object):
888
1179
  - Description: markdown, not null
889
1180
  - Synonyms: text[]
890
1181
 
891
- 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.
892
1186
 
893
1187
  """
894
1188
  if not re.match("^[A-Za-z][-_A-Za-z0-9]*:[{]RID[}]$", curie_template):
@@ -938,41 +1232,52 @@ class Table (object):
938
1232
 
939
1233
  def add_vocab_keys(custom):
940
1234
  def ktup(k):
941
- return tuple(k['unique_columns'])
1235
+ return frozenset(k['unique_columns'])
942
1236
  return [
943
1237
  key_def
944
1238
  for key_def in [
945
1239
  Key.define(['ID']),
946
1240
  Key.define(['URI']),
947
- ]
1241
+ ] + ([ Key.define(['Name']) ] if provide_name_key else [])
948
1242
  if ktup(key_def) not in { ktup(kdef): kdef for kdef in custom }
949
1243
  ] + custom
950
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
+
951
1250
  return cls.define(
952
1251
  tname,
953
- add_vocab_columns(column_defs),
954
- add_vocab_keys(key_defs),
1252
+ column_defs,
1253
+ key_defs,
955
1254
  fkey_defs,
956
1255
  comment,
957
1256
  acls,
958
1257
  acl_bindings,
959
1258
  annotations,
960
- provide_system
1259
+ provide_system=provide_system,
1260
+ provide_system_fkeys=provide_system_fkeys,
1261
+ key_column_search_order=key_column_search_order,
961
1262
  )
962
1263
 
963
1264
  @classmethod
964
- def define_asset(cls,
965
- sname,
966
- tname,
967
- hatrac_template=None,
968
- column_defs=[],
969
- key_defs=[],
970
- fkey_defs=[],
971
- comment=None,
972
- acls={},
973
- acl_bindings={},
974
- annotations={},
975
- 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
+ ):
976
1281
  """Build an asset table definition.
977
1282
 
978
1283
  :param sname: the name of the schema for the asset table
@@ -982,7 +1287,7 @@ class Table (object):
982
1287
  /hatrac/schema_name/table_name/md5.filename
983
1288
  where the filename and md5 value is computed on upload and the schema_name and table_name are the
984
1289
  values of the provided arguments. If value is set to False, no hatrac_template is used.
985
- :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)
986
1291
  :param key_defs: a list of Key.define() results for extra or overridden key constraint definitions
987
1292
  :param fkey_defs: a list of ForeignKey.define() results for foreign key definitions
988
1293
  :param comment: a comment string for the table
@@ -990,22 +1295,28 @@ class Table (object):
990
1295
  :param acl_bindings: a dictionary of dynamic ACL bindings
991
1296
  :param annotations: a dictionary of annotations
992
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
993
1300
 
994
1301
  These core asset table columns are generated automatically if
995
1302
  absent from the input column_defs.
996
1303
 
997
1304
  - Filename: ermrest_curie, unique not null, default curie template "%s:{RID}" % curie_prefix
998
1305
  - URL: Location of the asset, unique not null. Default template is:
999
- /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.
1000
1307
  - Length: Length of the asset.
1001
1308
  - MD5: text
1002
1309
  - Description: markdown, not null
1003
1310
 
1004
- 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.
1005
1315
 
1006
1316
  In addition to creating the columns, this function also creates an asset annotation on the URL column to
1007
1317
  facilitate use of the table by Chaise.
1008
- """
1318
+
1319
+ """
1009
1320
 
1010
1321
  if hatrac_template is None:
1011
1322
  hatrac_template = '/hatrac/{{$catalog.id}}/%s/%s/{{{MD5}}}.{{#encode}}{{{Filename}}}{{/encode}}' % (sname, tname)
@@ -1039,7 +1350,7 @@ class Table (object):
1039
1350
 
1040
1351
  def add_asset_keys(custom):
1041
1352
  def ktup(k):
1042
- return tuple(k['unique_columns'])
1353
+ return frozenset(k['unique_columns'])
1043
1354
 
1044
1355
  return [
1045
1356
  key_def
@@ -1060,6 +1371,9 @@ class Table (object):
1060
1371
  asset_annotations.update(custom)
1061
1372
  return asset_annotations
1062
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
+
1063
1377
  return cls.define(
1064
1378
  tname,
1065
1379
  add_asset_columns(column_defs),
@@ -1069,29 +1383,49 @@ class Table (object):
1069
1383
  acls,
1070
1384
  acl_bindings,
1071
1385
  add_asset_annotations(annotations),
1072
- provide_system
1386
+ provide_system=provide_system,
1387
+ provide_system_fkeys=provide_system_fkeys,
1388
+ key_column_search_order=key_column_search_order,
1073
1389
  )
1074
1390
 
1075
1391
  @classmethod
1076
- 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
+ ):
1077
1406
  """Build a wiki-like "page" table definition.
1078
1407
 
1079
1408
  :param tname: the name of the newly defined table
1080
1409
  :param column_defs: a list of Column.define() results for extra or overridden column definitions
1081
- :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)
1082
1411
  :param fkey_defs: a list of ForeignKey.define() results for foreign key definitions
1083
1412
  :param comment: a comment string for the table
1084
1413
  :param acls: a dictionary of ACLs for specific access modes
1085
1414
  :param acl_bindings: a dictionary of dynamic ACL bindings
1086
1415
  :param annotations: a dictionary of annotations
1087
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
1088
1419
 
1089
1420
  These core page columns are generated automatically if absent from the input column_defs.
1090
1421
 
1091
1422
  - Title: text, unique not null
1092
1423
  - Content: markdown
1093
1424
 
1094
- 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.
1095
1429
  """
1096
1430
 
1097
1431
  def add_page_columns(custom):
@@ -1116,7 +1450,7 @@ class Table (object):
1116
1450
 
1117
1451
  def add_page_keys(custom):
1118
1452
  def ktup(k):
1119
- return tuple(k['unique_columns'])
1453
+ return frozenset(k['unique_columns'])
1120
1454
  return [
1121
1455
  key_def
1122
1456
  for key_def in [
@@ -1146,6 +1480,9 @@ class Table (object):
1146
1480
  page_annotations.update(annotations)
1147
1481
  return page_annotations
1148
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
+
1149
1486
  return cls.define(
1150
1487
  tname,
1151
1488
  add_page_columns(column_defs),
@@ -1155,7 +1492,9 @@ class Table (object):
1155
1492
  acls,
1156
1493
  acl_bindings,
1157
1494
  add_page_annotations(annotations),
1158
- provide_system
1495
+ provide_system=provide_system,
1496
+ provide_system_fkeys=provide_system_fkeys,
1497
+ key_column_search_order=key_column_search_order,
1159
1498
  )
1160
1499
 
1161
1500
  @classmethod
@@ -1165,14 +1504,19 @@ class Table (object):
1165
1504
  metadata: Iterable[Key | Table | dict | tuple[str, bool, Key | Table]] = [],
1166
1505
  table_name: str | None = None,
1167
1506
  comment: str | None = None,
1168
- provide_system: bool = True) -> dict:
1507
+ provide_system: bool = True,
1508
+ provide_system_fkeys: bool = True,
1509
+ key_column_search_order: Iterable[str] | None = None,
1510
+ ) -> dict:
1169
1511
  """Build an association table definition.
1170
1512
 
1171
- :param associates: the existing Key instances being associated
1172
- :param metadata: additional metadata fields for impure associations
1513
+ :param associates: reference targets being associated (see below)
1514
+ :param metadata: additional metadata fields and/or reference targets for impure associations
1173
1515
  :param table_name: name for the association table or None for default naming
1174
1516
  :param comment: comment for the association table or None for default comment
1175
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
1176
1520
 
1177
1521
  This is a utility function to help build an association table
1178
1522
  definition. It simplifies the task, but removes some
@@ -1188,154 +1532,91 @@ class Table (object):
1188
1532
  An "impure" association table adds additional metadata
1189
1533
  alongside the N foreign keys.
1190
1534
 
1191
- The "associates" parameter takes an iterable of Key instances
1192
- from other tables. The association will be comprised of
1193
- foreign keys referencing these associates. Optionally, a tuple
1194
- of (str, Key) can supply a string _base name_ to influence how
1195
- the foreign key columns and constraint will be named in the
1196
- new association table. A bare Key instance will get a base
1197
- name derived from the referenced table name.
1198
-
1199
- The "metadata" parameter takes an iterable of plain dict
1200
- column definitions or Key instances. Each dict must be a
1201
- scalar column definition, such as produced by the
1202
- Column.define() class method. Key instance will cause
1203
- corresponding columns and foreign keys to be added to the
1204
- association table to act as metadata. Optionally, a tuple of
1205
- (str, bool, Key) can supply a string _base name_ and a boolean
1206
- _nullok_ property to influence how the foreign key columns and
1207
- constraint will be constructed and named. A bare Key instance
1208
- will get a base name derived from the referened table name,
1209
- and presumed as nullok=False.
1210
-
1211
- If a Table instance is supplied instead of a Key instance for
1212
- associates or metadata inputs, an attempt will be made to
1213
- locate a key based on the RID system column. If this key
1214
- cannot be found, a KeyError will be raised.
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.
1215
1552
 
1216
1553
  """
1217
1554
  associates = list(associates)
1218
1555
  metadata = list(metadata)
1219
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
+
1220
1563
  if len(associates) < 2:
1221
1564
  raise ValueError('An association table requires at least 2 associates')
1222
1565
 
1223
- cdefs = []
1224
- kdefs = []
1225
- fkdefs = []
1226
-
1227
1566
  used_names = set()
1228
1567
 
1229
- def check_basename(basename):
1230
- if not isinstance(base_name, str):
1231
- raise TypeError('Base name %r is not of required type str' % (base_name,))
1232
- if base_name in used_names:
1233
- raise ValueError('Base name %r is not unique among associates and metadata' % (base_name,))
1234
- used_names.add(base_name)
1235
-
1236
- def choose_basename(key):
1237
- base_name = key.table.name
1238
- n = 2
1239
- while base_name in used_names:
1240
- base_name = '%s%d' % (key.table.name, n)
1241
- n += 1
1242
- used_names.add(base_name)
1243
- return base_name
1244
-
1245
- def check_key(key):
1246
- if isinstance(key, Table):
1247
- return key.key_by_columns(["RID"])
1248
- return key
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,))
1249
1571
 
1250
- # check and normalize associates into list[(str, Key)] with distinct base names
1251
- for i in range(len(associates)):
1252
- if isinstance(associates[i], tuple):
1253
- base_name, key = associates[i]
1254
- check_basename(base_name)
1255
- key = check_key(key)
1256
- associates[i] = (base_name, key)
1257
- else:
1258
- key = check_key(associates[i])
1259
- base_name = choose_basename(key)
1260
- associates[i] = (base_name, key)
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)
1261
1576
 
1262
- # build assoc table name if not provided
1263
1577
  if table_name is None:
1264
- table_name = make_id(*[ assoc[1].table.name for assoc in associates ])
1265
-
1266
- def simplify_type(ctype):
1267
- if ctype.is_domain and ctype.typename.startswith('ermrest_'):
1268
- return ctype.base_type
1269
-
1270
- return ctype
1271
-
1272
- def cdefs_for_key(base_name, key, nullok=False):
1273
- return [
1274
- Column.define(
1275
- '%s_%s' % (base_name, col.name) if len(key.unique_columns) > 1 else base_name,
1276
- simplify_type(col.type),
1277
- nullok=nullok,
1278
- )
1279
- for col in key.unique_columns
1280
- ]
1281
-
1282
- def fkdef_for_key(base_name, key):
1283
- return ForeignKey.define(
1284
- [
1285
- '%s_%s' % (base_name, col.name) if len(key.unique_columns) > 1 else base_name
1286
- for col in key.unique_columns
1287
- ],
1288
- key.table.schema.name,
1289
- key.table.name,
1290
- [ col.name for col in key.unique_columns ],
1291
- on_update='CASCADE',
1292
- on_delete='CASCADE',
1293
- constraint_name=make_id(table_name, base_name, 'fkey'),
1294
- )
1295
-
1296
- # build core association definition (i.e. the "pure" parts)
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
1297
1595
  k_cnames = []
1298
- for base_name, key in associates:
1299
- cdefs.extend(cdefs_for_key(base_name, key))
1300
- fkdefs.append(fkdef_for_key(base_name, key))
1301
-
1302
- k_cnames.extend([
1303
- '%s_%s' % (base_name, col.name) if len(key.unique_columns) > 1 else base_name
1304
- for col in key.unique_columns
1305
- ])
1596
+ for fkdef in fkdefs:
1597
+ k_cnames.extend([ colref["column_name"] for colref in fkdef["foreign_key_columns"] ])
1306
1598
 
1307
- kdefs.append(
1599
+ kdefs = [
1308
1600
  Key.define(
1309
1601
  k_cnames,
1310
1602
  constraint_name=make_id(table_name, 'assoc', 'key'),
1311
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,
1312
1618
  )
1313
1619
 
1314
- # check and normalize metadata into list[dict | (str, bool, Key)]
1315
- for i in range(len(metadata)):
1316
- if isinstance(metadata[i], tuple):
1317
- base_name, nullok, key = metadata[i]
1318
- check_basename(base_name)
1319
- key = check_key(key)
1320
- metadata[i] = (base_name, nullok, key)
1321
- elif isinstance(metadata[i], dict):
1322
- pass
1323
- else:
1324
- key = check_key(metadata[i])
1325
- base_name = choose_basename(key)
1326
- metadata[i] = (base_name, False, key)
1327
-
1328
- # add metadata to definition
1329
- for md in metadata:
1330
- if isinstance(md, dict):
1331
- cdefs.append(md)
1332
- else:
1333
- base_name, nullok, key = md
1334
- cdefs.extend(cdefs_for_key(base_name, key, nullok))
1335
- fkdefs.append(fkdef_for_key(base_name, key))
1336
-
1337
- return Table.define(table_name, cdefs, kdefs, fkdefs, comment=comment, provide_system=provide_system)
1338
-
1339
1620
  def prejson(self, prune=True):
1340
1621
  return {
1341
1622
  "schema_name": self.schema.name,
@@ -1416,7 +1697,8 @@ class Table (object):
1416
1697
  comment=nochange,
1417
1698
  acls=nochange,
1418
1699
  acl_bindings=nochange,
1419
- annotations=nochange
1700
+ annotations=nochange,
1701
+ update_mappings=UpdateMappings.no_update
1420
1702
  ):
1421
1703
  """Alter existing schema definition.
1422
1704
 
@@ -1426,6 +1708,7 @@ class Table (object):
1426
1708
  :param acls: Replacement ACL configuration (default nochange)
1427
1709
  :param acl_bindings: Replacement ACL bindings (default nochange)
1428
1710
  :param annotations: Replacement annotations (default nochange)
1711
+ :param update_mappings: Update annotations to reflect changes (default UpdateMappings.no_updates)
1429
1712
 
1430
1713
  A change of schema name is a transfer of the existing table to
1431
1714
  an existing destination schema (not a rename of the current
@@ -1453,14 +1736,25 @@ class Table (object):
1453
1736
  self.schema.tables[self.name] = self
1454
1737
 
1455
1738
  if 'schema_name' in changes:
1739
+ old_schema_name = self.schema.name
1456
1740
  del self.schema.tables[self.name]
1457
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
1458
1745
  for fkey in self.foreign_keys:
1459
1746
  if fkey.constraint_schema:
1460
1747
  del fkey.constraint_schema._fkeys[fkey.constraint_name]
1461
1748
  fkey.constraint_schema = self.schema
1462
1749
  fkey.constraint_schema._fkeys[fkey.constraint_name] = fkey
1463
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()
1464
1758
 
1465
1759
  if 'comment' in changes:
1466
1760
  self.comment = changed['comment']
@@ -1501,7 +1795,7 @@ class Table (object):
1501
1795
  created = created[0]
1502
1796
  return registerfunc(constructor(self, created))
1503
1797
 
1504
- def create_column(self, column_def):
1798
+ def create_column(self, column_def: dict) -> Column:
1505
1799
  """Add a new column to this table in the remote database based on column_def.
1506
1800
 
1507
1801
  Returns a new Column instance based on the server-supplied
@@ -1516,7 +1810,7 @@ class Table (object):
1516
1810
  return col
1517
1811
  return self._create_table_part('column', add_column, Column, column_def)
1518
1812
 
1519
- def create_key(self, key_def):
1813
+ def create_key(self, key_def: dict) -> Key:
1520
1814
  """Add a new key to this table in the remote database based on key_def.
1521
1815
 
1522
1816
  Returns a new Key instance based on the server-supplied
@@ -1529,12 +1823,12 @@ class Table (object):
1529
1823
  return key
1530
1824
  return self._create_table_part('key', add_key, Key, key_def)
1531
1825
 
1532
- def create_fkey(self, fkey_def):
1826
+ def create_fkey(self, fkey_def: dict) -> ForeignKey:
1533
1827
  """Add a new foreign key to this table in the remote database based on fkey_def.
1534
1828
 
1535
1829
  Returns a new ForeignKey instance based on the
1536
1830
  server-supplied representation of the new foreign key, and
1537
- adds it to self.fkeys too.
1831
+ adds it to self.foreign_keys too.
1538
1832
 
1539
1833
  """
1540
1834
  def add_fkey(fkey):
@@ -1543,16 +1837,53 @@ class Table (object):
1543
1837
  return fkey
1544
1838
  return self._create_table_part('foreignkey', add_fkey, ForeignKey, fkey_def)
1545
1839
 
1546
- 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):
1547
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)
1548
1868
  """
1549
1869
  if self.name not in self.schema.tables:
1550
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
+
1551
1876
  self.catalog.delete(self.uri_path).raise_for_status()
1552
1877
  del self.schema.tables[self.name]
1553
1878
  for fkey in self.foreign_keys:
1554
1879
  fkey._cleanup()
1555
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
+
1556
1887
  def key_by_columns(self, unique_columns, raise_nomatch=True):
1557
1888
  """Return key from self.keys with matching unique columns.
1558
1889
 
@@ -1742,7 +2073,39 @@ class Table (object):
1742
2073
  # arbitrarily choose first fkey to self
1743
2074
  # in case association is back to same table
1744
2075
  break
1745
-
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
+ })
2108
+
1746
2109
  @presence_annotation(tag.immutable)
1747
2110
  def immutable(self): pass
1748
2111
 
@@ -1840,6 +2203,17 @@ class Column (object):
1840
2203
  self.default = column_doc.get('default')
1841
2204
  self.comment = column_doc.get('comment')
1842
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
+
1843
2217
  @property
1844
2218
  def catalog(self):
1845
2219
  return self.table.schema.model.catalog
@@ -1934,7 +2308,8 @@ class Column (object):
1934
2308
  comment=nochange,
1935
2309
  acls=nochange,
1936
2310
  acl_bindings=nochange,
1937
- annotations=nochange
2311
+ annotations=nochange,
2312
+ update_mappings=UpdateMappings.no_update
1938
2313
  ):
1939
2314
  """Alter existing schema definition.
1940
2315
 
@@ -1946,6 +2321,7 @@ class Column (object):
1946
2321
  :param acls: Replacement ACL configuration (default nochange)
1947
2322
  :param acl_bindings: Replacement ACL bindings (default nochange)
1948
2323
  :param annotations: Replacement annotations (default nochange)
2324
+ :param update_mappings: Update annotations to reflect changes (default UpdateMappings.no_updates)
1949
2325
 
1950
2326
  Returns self (to allow for optional chained access).
1951
2327
 
@@ -1972,8 +2348,14 @@ class Column (object):
1972
2348
 
1973
2349
  if 'name' in changes:
1974
2350
  del self.table.column_definitions.elements[self.name]
2351
+ oldname = self.name
1975
2352
  self.name = changed['name']
1976
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()
1977
2359
 
1978
2360
  if 'type' in changes:
1979
2361
  self.type = make_type(changed['type'])
@@ -2001,14 +2383,41 @@ class Column (object):
2001
2383
 
2002
2384
  return self
2003
2385
 
2004
- def drop(self):
2386
+ def drop(self, cascade=False, update_mappings=UpdateMappings.no_update):
2005
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)
2006
2391
  """
2007
2392
  if self.name not in self.table.column_definitions.elements:
2008
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
+
2009
2403
  self.catalog.delete(self.uri_path).raise_for_status()
2010
2404
  del self.table.column_definitions[self.name]
2011
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
+
2012
2421
  @presence_annotation(tag.immutable)
2013
2422
  def immutable(self): pass
2014
2423
 
@@ -2036,6 +2445,19 @@ def _constraint_name_parts(constraint, doc):
2036
2445
  constraint_schema = None
2037
2446
  elif names[0][0] == constraint.table.schema.name:
2038
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
2039
2461
  else:
2040
2462
  raise ValueError('Unexpected schema name in constraint %s' % (names[0],))
2041
2463
  constraint_name = names[0][1]
@@ -2057,6 +2479,16 @@ class Key (object):
2057
2479
  for cname in key_doc['unique_columns']
2058
2480
  ])
2059
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
+
2060
2492
  @property
2061
2493
  def columns(self):
2062
2494
  """Sugared access to self.unique_columns"""
@@ -2174,13 +2606,15 @@ class Key (object):
2174
2606
  self,
2175
2607
  constraint_name=nochange,
2176
2608
  comment=nochange,
2177
- annotations=nochange
2609
+ annotations=nochange,
2610
+ update_mappings=UpdateMappings.no_update
2178
2611
  ):
2179
2612
  """Alter existing schema definition.
2180
2613
 
2181
2614
  :param constraint_name: Unqualified constraint name string
2182
2615
  :param comment: Replacement comment (default nochange)
2183
2616
  :param annotations: Replacement annotations (default nochange)
2617
+ :param update_mappings: Update annotations to reflect changes (default UpdateMappings.no_updates)
2184
2618
 
2185
2619
  Returns self (to allow for optional chained access).
2186
2620
 
@@ -2200,7 +2634,13 @@ class Key (object):
2200
2634
  changed = r.json() # use changed vs changes to get server-digested values
2201
2635
 
2202
2636
  if 'names' in changes:
2637
+ oldname = self.constraint_name
2203
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()
2204
2644
 
2205
2645
  if 'comment' in changes:
2206
2646
  self.comment = changed['comment']
@@ -2211,14 +2651,34 @@ class Key (object):
2211
2651
 
2212
2652
  return self
2213
2653
 
2214
- def drop(self):
2654
+ def drop(self, cascade=False, update_mappings=UpdateMappings.no_update):
2215
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)
2216
2659
  """
2217
2660
  if self.name not in self.table.keys.elements:
2218
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
+
2219
2669
  self.catalog.delete(self.uri_path).raise_for_status()
2220
2670
  del self.table.keys[self.name]
2221
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
+
2222
2682
  class ForeignKey (object):
2223
2683
  """Named foreign key.
2224
2684
  """
@@ -2246,6 +2706,16 @@ class ForeignKey (object):
2246
2706
  self._referenced_columns_doc = fkey_doc['referenced_columns']
2247
2707
  self.referenced_columns = None
2248
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
+
2249
2719
  def digest_referenced_columns(self, model):
2250
2720
  """Finish construction deferred until model is known with all tables."""
2251
2721
  if self.referenced_columns is None:
@@ -2258,6 +2728,13 @@ class ForeignKey (object):
2258
2728
  ])
2259
2729
  self._referenced_columns_doc = None
2260
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
2261
2738
 
2262
2739
  @property
2263
2740
  def column_map(self):
@@ -2437,7 +2914,8 @@ class ForeignKey (object):
2437
2914
  comment=nochange,
2438
2915
  acls=nochange,
2439
2916
  acl_bindings=nochange,
2440
- annotations=nochange
2917
+ annotations=nochange,
2918
+ update_mappings=UpdateMappings.no_update
2441
2919
  ):
2442
2920
  """Alter existing schema definition.
2443
2921
 
@@ -2448,6 +2926,7 @@ class ForeignKey (object):
2448
2926
  :param acls: Replacement ACL configuration (default nochange)
2449
2927
  :param acl_bindings: Replacement ACL bindings (default nochange)
2450
2928
  :param annotations: Replacement annotations (default nochange)
2929
+ :param update_mappings: Update annotations to reflect changes (default UpdateMappings.no_updates)
2451
2930
 
2452
2931
  Returns self (to allow for optional chained access).
2453
2932
 
@@ -2475,11 +2954,17 @@ class ForeignKey (object):
2475
2954
  del self.constraint_schema._fkeys[self.constraint_name]
2476
2955
  else:
2477
2956
  del self.table.schema.model._pseudo_fkeys[self.constraint_name]
2957
+ oldname = self.constraint_name
2478
2958
  self.constraint_name = changed['names'][0][1]
2479
2959
  if self.constraint_schema:
2480
2960
  self.constraint_schema._fkeys[self.constraint_name] = self
2481
2961
  else:
2482
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()
2483
2968
 
2484
2969
  if 'on_update' in changes:
2485
2970
  self.on_update = changed['on_update']
@@ -2504,8 +2989,10 @@ class ForeignKey (object):
2504
2989
 
2505
2990
  return self
2506
2991
 
2507
- def drop(self):
2992
+ def drop(self, update_mappings=UpdateMappings.no_update):
2508
2993
  """Remove this foreign key from the remote database.
2994
+
2995
+ :param update_mappings: Update annotations to reflect changes (default UpdateMappings.no_updates)
2509
2996
  """
2510
2997
  if self.name not in self.table.foreign_keys.elements:
2511
2998
  raise ValueError('Foreign key %s does not appear to belong to table %s.' % (self, self.table))
@@ -2513,6 +3000,11 @@ class ForeignKey (object):
2513
3000
  del self.table.foreign_keys[self.name]
2514
3001
  self._cleanup()
2515
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
+
2516
3008
  def _cleanup(self):
2517
3009
  """Cleanup references in the local model following drop from remote database.
2518
3010
  """
@@ -2548,6 +3040,22 @@ class Type (object):
2548
3040
  }
2549
3041
  return d
2550
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
+
2551
3059
  class DomainType (Type):
2552
3060
  """Named domain type.
2553
3061
  """
@@ -2564,6 +3072,10 @@ class DomainType (Type):
2564
3072
  })
2565
3073
  return d
2566
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
+
2567
3079
  class ArrayType (Type):
2568
3080
  """Named domain type.
2569
3081
  """
@@ -2580,6 +3092,10 @@ class ArrayType (Type):
2580
3092
  })
2581
3093
  return d
2582
3094
 
3095
+ def sqlite3_ddl(self) -> str:
3096
+ """Return a SQLite3 column type DDL fragment for this type"""
3097
+ return 'json'
3098
+
2583
3099
  builtin_types = AttrDict(
2584
3100
  # first define standard scalar types
2585
3101
  {