Skip to content

Commit f692289

Browse files
authored
UDTs: complete support (#367)
* expand list_types, class and unit test to unsupported items * integration test for unsupported UDTs * add (now-failing) test for short-form in collection columns with prim.types * enhance special test to guard against longform in collection column schema * Final handling of null/omitted/incomplete UDTs in responses, with unit testing * add maps-as-lists to the unit test for incomplete udts * one more representation for empty-dict udts * integration tests for nulls/partial UDTs on tables * integration test for UDT-related filtering * add async udt-filtering IT * builder interface for CreateTypeDefinition * adapt 'test_table_userdefinedtypes_[a]sync to null/partial udt final specs
1 parent 4555365 commit f692289

14 files changed

+1244
-158
lines changed

astrapy/data/database.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from __future__ import annotations
1616

1717
import logging
18+
import warnings
1819
from types import TracebackType
1920
from typing import TYPE_CHECKING, Any, overload
2021

@@ -1844,10 +1845,18 @@ def list_types(
18441845
)
18451846
else:
18461847
logger.info("finished listTypes")
1847-
return [
1848-
ListTypeDescriptor._from_dict(type_json)
1849-
for type_json in lt_response["status"]["types"]
1850-
]
1848+
all_types_json = lt_response["status"]["types"]
1849+
lt_descriptors: list[ListTypeDescriptor] = []
1850+
for type_json in all_types_json:
1851+
if ListTypeDescriptor._is_valid_dict(type_json):
1852+
lt_descriptors.append(ListTypeDescriptor._from_dict(type_json))
1853+
else:
1854+
warnings.warn(
1855+
"Unexpected item encountered while reading the response of "
1856+
"listTypes. the offending item will be skipped from the "
1857+
f"return value of `list_types`. Its value is: '{type_json}'."
1858+
)
1859+
return lt_descriptors
18511860

18521861
def alter_type(
18531862
self,
@@ -4001,10 +4010,18 @@ async def list_types(
40014010
)
40024011
else:
40034012
logger.info("finished listTypes")
4004-
return [
4005-
ListTypeDescriptor._from_dict(type_json)
4006-
for type_json in lt_response["status"]["types"]
4007-
]
4013+
all_types_json = lt_response["status"]["types"]
4014+
lt_descriptors: list[ListTypeDescriptor] = []
4015+
for type_json in all_types_json:
4016+
if ListTypeDescriptor._is_valid_dict(type_json):
4017+
lt_descriptors.append(ListTypeDescriptor._from_dict(type_json))
4018+
else:
4019+
warnings.warn(
4020+
"Unexpected item encountered while reading the response of "
4021+
"listTypes. the offending item will be skipped from the "
4022+
f"return value of `list_types`. Its value is: '{type_json}'."
4023+
)
4024+
return lt_descriptors
40084025

40094026
async def alter_type(
40104027
self,

astrapy/data/info/table_descriptor/type_creation.py

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@
1717
from dataclasses import dataclass
1818
from typing import Any
1919

20-
from astrapy.data.info.table_descriptor.table_columns import TableColumnTypeDescriptor
20+
from astrapy.data.info.table_descriptor.table_columns import (
21+
TableColumnTypeDescriptor,
22+
TableScalarColumnTypeDescriptor,
23+
)
24+
from astrapy.data.utils.table_types import ColumnType
2125
from astrapy.utils.parsing import _warn_residual_keys
2226

2327

@@ -30,9 +34,9 @@ class CreateTypeDefinition:
3034
3135
See the Data API specifications for detailed specification and allowed values.
3236
33-
Instances of this object can be created either by passing a fully-formed
34-
definition to the class constructor, or coercing an appropriately-shaped
35-
plain dictionary into this class.
37+
Instances of this object can be created in three ways: using a fluent interface,
38+
passing a fully-formed definition to the class constructor, or coercing an
39+
appropriately-shaped plain dictionary into this class.
3640
3741
Attributes:
3842
fields: a map from field names to their type definition object. This follows
@@ -42,6 +46,14 @@ class CreateTypeDefinition:
4246
>>> from astrapy.info import CreateTypeDefinition
4347
>>> from astrapy.info import ColumnType, TableScalarColumnTypeDescriptor
4448
>>>
49+
>>> type_definition_0 = (
50+
... CreateTypeDefinition.builder()
51+
... .add_field("tagline", ColumnType.TEXT)
52+
... .add_field("score", ColumnType.INT)
53+
... .add_field("height", "float") # plain strings accepted for field types
54+
... .build()
55+
... )
56+
>>>
4557
>>> type_definition_1 = CreateTypeDefinition(fields={
4658
... "tagline": TableScalarColumnTypeDescriptor(ColumnType.TEXT),
4759
... "score": TableScalarColumnTypeDescriptor(ColumnType.INT),
@@ -65,6 +77,8 @@ class CreateTypeDefinition:
6577
... },
6678
... }
6779
>>> type_definition_3_mixed = CreateTypeDefinition.coerce(fields_dict_3)
80+
>>> type_definition_0 == type_definition_1
81+
True
6882
>>> type_definition_1 == type_definition_2
6983
True
7084
>>> type_definition_2 == type_definition_3_mixed
@@ -122,3 +136,68 @@ def coerce(
122136
return raw_input
123137
else:
124138
return cls._from_dict(raw_input)
139+
140+
@staticmethod
141+
def builder() -> CreateTypeDefinition:
142+
"""
143+
Create an "empty" builder for constructing a type definition through
144+
a fluent interface. The resulting object has no fields yet: those are to
145+
be added progressively with the `add_field` method.
146+
147+
Being a "type without fields", the type definition returned by this method
148+
cannot be directly used to create a type.
149+
150+
See the class docstring for a full example on using the fluent interface.
151+
152+
Returns:
153+
a CreateTypeDefinition formally describing a type without fields.
154+
"""
155+
156+
return CreateTypeDefinition(fields={})
157+
158+
def add_field(
159+
self, field_name: str, field_type: str | ColumnType
160+
) -> CreateTypeDefinition:
161+
"""
162+
Return a new type definition object with an added field
163+
of a scalar type (i.e. not a list, set or other composite type).
164+
This method is for use within the fluent interface for progressively
165+
building the final type definition.
166+
167+
See the class docstring for a full example on using the fluent interface.
168+
169+
Args:
170+
field_name: the name of the new field to add to the type.
171+
field_type: a string, or a `ColumnType` value, defining
172+
the scalar type for the field.
173+
174+
Returns:
175+
a CreateTypeDefinition obtained by adding (or replacing) the desired
176+
field to this type definition.
177+
"""
178+
179+
return CreateTypeDefinition(
180+
fields={
181+
**self.fields,
182+
**{
183+
field_name: TableScalarColumnTypeDescriptor(
184+
column_type=ColumnType.coerce(field_type)
185+
)
186+
},
187+
},
188+
)
189+
190+
def build(self) -> CreateTypeDefinition:
191+
"""
192+
The final step in the fluent (builder) interface. Calling this method
193+
finalizes the definition that has been built so far and makes it into a
194+
type definition ready for use e.g. with the database's `create_type` method.
195+
196+
See the class docstring for a full example on using the fluent interface.
197+
198+
Returns:
199+
a CreateTypeDefinition obtained by finalizing the definition being
200+
built so far.
201+
"""
202+
203+
return self

astrapy/data/info/table_descriptor/type_listing.py

Lines changed: 61 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@
1414

1515
from __future__ import annotations
1616

17-
import warnings
1817
from dataclasses import dataclass
1918
from typing import Any
2019

2120
from astrapy.data.info.table_descriptor.table_columns import TableAPISupportDescriptor
2221
from astrapy.data.info.table_descriptor.type_creation import CreateTypeDefinition
23-
from astrapy.data.utils.table_types import TableUDTColumnType
22+
from astrapy.data.utils.table_types import (
23+
TableUDTColumnType,
24+
TableUnsupportedColumnType,
25+
)
2426
from astrapy.utils.parsing import _warn_residual_keys
2527

2628

@@ -29,47 +31,78 @@ class ListTypeDescriptor:
2931
"""
3032
A structure describing a user-defined type (UDT) stored on the database.
3133
32-
This description provides all information, including the UDT name as found
33-
on the database and possibly a sub-object detailing the allowed operations
34-
with the UDT. `ListTypeDescriptor` objects are used for the return
35-
type of the database `list_types` method.
34+
This object is used for the items returned by the database `list_types` method.
35+
`ListTypeDescriptor` expresses all information received by the Data API, including
36+
(when provided) the UDT name as found on the database, the UDT name and possibly a
37+
sub-object detailing the allowed operations with the UDT.
38+
39+
This object must be able to describe any item returned from the Data API:
40+
this means it can describe "unsupported" UDTs as well (i.e. those which have been
41+
created outside of the Data API). Unsupported UDTs lack some attributes compared
42+
to the fully-supported ones.
3643
3744
Attributes:
45+
udt_type: a value of either the TableUDTColumnType or
46+
the TableUnsupportedColumnType enum, depending on the UDT support status.
3847
udt_name: the name of the UDT as is stored in the database (and in a keyspace).
3948
definition: the definition of the type, i.e. its fields and their types.
4049
api_support: a structure detailing what operations the type supports.
4150
"""
4251

43-
udt_name: str
44-
definition: CreateTypeDefinition
52+
udt_type: TableUDTColumnType | TableUnsupportedColumnType
53+
udt_name: str | None
54+
definition: CreateTypeDefinition | None
4555
api_support: TableAPISupportDescriptor | None
4656

4757
def __init__(
4858
self,
4959
*,
50-
udt_name: str,
51-
definition: CreateTypeDefinition,
60+
udt_type: TableUDTColumnType | TableUnsupportedColumnType,
61+
udt_name: str | None,
62+
definition: CreateTypeDefinition | None,
5263
api_support: TableAPISupportDescriptor | None,
5364
) -> None:
65+
self.udt_type = udt_type
5466
self.udt_name = udt_name
5567
self.definition = definition
5668
self.api_support = api_support
5769

5870
def __repr__(self) -> str:
59-
return f"{self.__class__.__name__}({self.udt_name}: {self.definition})"
71+
if isinstance(self.udt_type, TableUnsupportedColumnType):
72+
return f"{self.__class__.__name__}({self.udt_type.value})"
73+
else:
74+
return f"{self.__class__.__name__}({self.udt_name}: {self.definition})"
75+
76+
@staticmethod
77+
def _is_valid_dict(raw_dict: dict[str, Any]) -> bool:
78+
"""
79+
Assess whether a dictionary can be converted into a ListTypeDescriptor.
80+
81+
This can be used by e.g. the database `list_types` method to filter
82+
offending responses and issue warnings if needed.
83+
84+
Returns:
85+
True if and only if the dict is valid, otherwise False.
86+
"""
87+
88+
return all(fld in raw_dict for fld in {"type", "apiSupport"})
6089

6190
def as_dict(self) -> dict[str, Any]:
6291
"""Recast this object into a dictionary."""
6392

6493
return {
65-
"type": TableUDTColumnType.USERDEFINED.value,
66-
"udtName": self.udt_name,
67-
"definition": self.definition.as_dict(),
68-
**(
69-
{"apiSupport": self.api_support.as_dict()}
94+
k: v
95+
for k, v in {
96+
"type": self.udt_type.value,
97+
"udtName": self.udt_name,
98+
"definition": self.definition.as_dict()
99+
if self.definition is not None
100+
else None,
101+
"apiSupport": self.api_support.as_dict()
70102
if self.api_support is not None
71-
else {}
72-
),
103+
else None,
104+
}.items()
105+
if v is not None
73106
}
74107

75108
@classmethod
@@ -82,16 +115,17 @@ def _from_dict(cls, raw_dict: dict[str, Any]) -> ListTypeDescriptor:
82115
_warn_residual_keys(
83116
cls, raw_dict, {"type", "udtName", "definition", "apiSupport"}
84117
)
85-
if "type" in raw_dict:
86-
if raw_dict["type"] != TableUDTColumnType.USERDEFINED.value:
87-
warnings.warn(
88-
"Unexpected 'type' found in a UDT description from the Data API: "
89-
f"{repr(raw_dict['type'])} "
90-
f"(for user-defined type '{raw_dict.get('udtName')}')."
91-
)
118+
_udt_type: TableUDTColumnType | TableUnsupportedColumnType
119+
if raw_dict["type"] in TableUDTColumnType:
120+
_udt_type = TableUDTColumnType.coerce(raw_dict["type"])
121+
else:
122+
_udt_type = TableUnsupportedColumnType.coerce(raw_dict["type"])
92123
return ListTypeDescriptor(
93-
udt_name=raw_dict["udtName"],
94-
definition=CreateTypeDefinition._from_dict(raw_dict["definition"]),
124+
udt_type=_udt_type,
125+
udt_name=raw_dict.get("udtName"),
126+
definition=CreateTypeDefinition._from_dict(raw_dict["definition"])
127+
if "definition" in raw_dict
128+
else None,
95129
api_support=TableAPISupportDescriptor._from_dict(raw_dict["apiSupport"])
96130
if "apiSupport" in raw_dict
97131
else None,

0 commit comments

Comments
 (0)