Skip to content

Commit b532f4b

Browse files
authored
Implement TIME type, stored to Databend DateTime type (#42)
* Implement TIME type, stored to Databend DateTime type * Fix - views are no longer included in system.tables * Change visit name * Add interval type (stored as datetime) Fix literal processors * Fixes for interval * Adds reserved words * Handle views now back in information_schema.tables
1 parent a78be72 commit b532f4b

File tree

4 files changed

+236
-18
lines changed

4 files changed

+236
-18
lines changed

databend_sqlalchemy/connector.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import decimal
77
import re
88
import uuid
9-
from datetime import datetime, date
9+
from datetime import datetime, date, time, timedelta
1010
from databend_sqlalchemy.errors import Error, ServerException, NotSupportedError
1111

1212
from databend_driver import BlockingDatabendClient
@@ -48,7 +48,7 @@ def escape_item(self, item):
4848
return self.escape_number(item)
4949
elif isinstance(item, decimal.Decimal):
5050
return self.escape_number(item)
51-
elif isinstance(item, (datetime, date)):
51+
elif isinstance(item, (datetime, date, time, timedelta)):
5252
return self.escape_string(item.strftime("%Y-%m-%d %H:%M:%S"))
5353
else:
5454
return self.escape_string(item)

databend_sqlalchemy/databend_dialect.py

+181-15
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,74 @@
5252
from sqlalchemy.exc import DBAPIError, NoSuchTableError
5353
from .dml import Merge
5454

55+
RESERVED_WORDS = {
56+
'Error', 'EOI', 'Whitespace', 'Comment', 'CommentBlock', 'Ident', 'ColumnPosition', 'LiteralString',
57+
'LiteralCodeString', 'LiteralAtString', 'PGLiteralHex', 'MySQLLiteralHex', 'LiteralInteger', 'LiteralFloat',
58+
'HintPrefix', 'HintSuffix', 'DoubleEq', 'Eq', 'NotEq', 'Lt', 'Gt', 'Lte', 'Gte', 'Spaceship', 'Plus',
59+
'Minus', 'Multiply', 'Divide', 'IntDiv', 'Modulo', 'StringConcat', 'LParen', 'RParen', 'Comma', 'Dot',
60+
'Colon', 'DoubleColon', 'ColonEqual', 'SemiColon', 'Backslash', 'LBracket', 'RBracket', 'Caret', 'LBrace',
61+
'RBrace', 'RArrow', 'LongRArrow', 'FatRArrow', 'HashRArrow', 'HashLongRArrow', 'TildeAsterisk',
62+
'ExclamationMarkTilde', 'ExclamationMarkTildeAsterisk', 'BitWiseAnd', 'BitWiseOr', 'BitWiseXor',
63+
'BitWiseNot', 'ShiftLeft', 'ShiftRight', 'Factorial', 'DoubleExclamationMark', 'Abs', 'SquareRoot',
64+
'CubeRoot', 'Placeholder', 'QuestionOr', 'QuestionAnd', 'ArrowAt', 'AtArrow', 'AtQuestion', 'AtAt',
65+
'HashMinus', 'ACCOUNT', 'ALL', 'ALLOWED_IP_LIST', 'ADD', 'AFTER', 'AGGREGATING', 'ANY', 'APPEND_ONLY',
66+
'ARGS', 'AUTO', 'SOME', 'ALTER', 'ALWAYS', 'ANALYZE', 'AND', 'ARRAY', 'AS', 'AST', 'AT', 'ASC',
67+
'ANTI', 'ASYNC', 'ATTACH', 'BEFORE', 'BETWEEN', 'BIGINT', 'BINARY', 'BREAK', 'LONGBLOB', 'MEDIUMBLOB',
68+
'TINYBLOB', 'BLOB', 'BINARY_FORMAT', 'BITMAP', 'BLOCKED_IP_LIST', 'BOOL', 'BOOLEAN', 'BOTH', 'BY',
69+
'BROTLI', 'BZ2', 'CALL', 'CASE', 'CAST', 'CATALOG', 'CATALOGS', 'CENTURY', 'CHANGES', 'CLUSTER',
70+
'COMMENT', 'COMMENTS', 'COMPACT', 'CONNECTION', 'CONNECTIONS', 'CONSUME', 'CONTENT_TYPE', 'CONTINUE',
71+
'CHAR', 'COLUMN', 'COLUMNS', 'CHARACTER', 'CONFLICT', 'COMPRESSION', 'COPY_OPTIONS', 'COPY', 'COUNT',
72+
'CREDENTIAL', 'CREATE', 'CROSS', 'CSV', 'CURRENT', 'CURRENT_TIMESTAMP', 'DATABASE', 'DATABASES', 'DATA',
73+
'DATE', 'DATE_ADD', 'DATE_PART', 'DATE_SUB', 'DATE_TRUNC', 'DATETIME', 'DAY', 'DECADE', 'DECIMAL',
74+
'DECLARE', 'DEFAULT', 'DEFLATE', 'DELETE', 'DESC', 'DETAILED_OUTPUT', 'DESCRIBE', 'DISABLE',
75+
'DISABLE_VARIANT_CHECK', 'DISTINCT', 'RESPECT', 'IGNORE', 'DIV', 'DOUBLE_SHA1_PASSWORD', 'DO', 'DOUBLE',
76+
'DOW', 'WEEK', 'DELTA', 'DOY', 'DOWNLOAD', 'DOWNSTREAM', 'DROP', 'DRY', 'DYNAMIC', 'EXCEPT', 'EXCLUDE',
77+
'ELSE', 'EMPTY_FIELD_AS', 'ENABLE', 'ENABLE_VIRTUAL_HOST_STYLE', 'END', 'ENDPOINT', 'ENGINE', 'ENGINES',
78+
'EPOCH', 'ERROR_ON_COLUMN_COUNT_MISMATCH', 'ESCAPE', 'EXCEPTION_BACKTRACE', 'EXISTS', 'EXPLAIN', 'EXPIRE',
79+
'EXTRACT', 'ELSEIF', 'FALSE', 'FIELDS', 'FIELD_DELIMITER', 'NAN_DISPLAY', 'NULL_DISPLAY', 'NULL_IF',
80+
'FILE_FORMAT', 'FILE', 'FILES', 'FINAL', 'FLASHBACK', 'FLOAT', 'FLOAT32', 'FLOAT64', 'FOR', 'FORCE',
81+
'FORMAT', 'FOLLOWING', 'FORMAT_NAME', 'FORMATS', 'FRAGMENTS', 'FROM', 'FULL', 'FUNCTION', 'FUNCTIONS',
82+
'TABLE_FUNCTIONS', 'SET_VAR', 'FUSE', 'GET', 'GENERATED', 'GEOMETRY', 'GLOBAL', 'GRAPH', 'GROUP', 'GZIP',
83+
'HAVING', 'HIGH', 'HISTORY', 'HIVE', 'HOUR', 'HOURS', 'ICEBERG', 'INTERSECT', 'IDENTIFIED', 'IDENTIFIER',
84+
'IF', 'IN', 'INCREMENTAL', 'INDEX', 'INFORMATION', 'INITIALIZE', 'INNER', 'INSERT', 'INT', 'INT16',
85+
'INT32', 'INT64', 'INT8', 'INTEGER', 'INTERVAL', 'INTO', 'INVERTED', 'IMMEDIATE', 'IS', 'ISODOW',
86+
'ISOYEAR', 'JOIN', 'JSON', 'JULIAN', 'JWT', 'KEY', 'KILL', 'LATERAL', 'LOCATION_PREFIX', 'LOCKS',
87+
'LOGICAL', 'LOOP', 'SECONDARY', 'ROLES', 'L2DISTANCE', 'LEADING', 'LEFT', 'LET', 'LIKE', 'LIMIT',
88+
'LIST', 'LOW', 'LZO', 'MASKING', 'MAP', 'MAX_FILE_SIZE', 'MASTER_KEY', 'MEDIUM', 'MEMO', 'MEMORY',
89+
'METRICS', 'MICROSECONDS', 'MILLENNIUM', 'MILLISECONDS', 'MINUTE', 'MONTH', 'MODIFY', 'MATERIALIZED',
90+
'MUST_CHANGE_PASSWORD', 'NON_DISPLAY', 'NATURAL', 'NETWORK', 'DISABLED', 'NDJSON', 'NO_PASSWORD', 'NONE',
91+
'NOT', 'NOTENANTSETTING', 'DEFAULT_ROLE', 'NULL', 'NULLABLE', 'OBJECT', 'OF', 'OFFSET', 'ON',
92+
'ON_CREATE', 'ON_SCHEDULE', 'OPTIMIZE', 'OPTIONS', 'OR', 'ORC', 'ORDER', 'OUTPUT_HEADER', 'OUTER',
93+
'ON_ERROR', 'OVER', 'OVERWRITE', 'PARTITION', 'PARQUET', 'PASSWORD', 'PASSWORD_MIN_LENGTH',
94+
'PASSWORD_MAX_LENGTH', 'PASSWORD_MIN_UPPER_CASE_CHARS', 'PASSWORD_MIN_LOWER_CASE_CHARS',
95+
'PASSWORD_MIN_NUMERIC_CHARS', 'PASSWORD_MIN_SPECIAL_CHARS', 'PASSWORD_MIN_AGE_DAYS', 'PASSWORD_MAX_AGE_DAYS',
96+
'PASSWORD_MAX_RETRIES', 'PASSWORD_LOCKOUT_TIME_MINS', 'PASSWORD_HISTORY', 'PATTERN', 'PIPELINE',
97+
'PLAINTEXT_PASSWORD', 'POLICIES', 'POLICY', 'POSITION', 'PROCESSLIST', 'PRIORITY', 'PURGE', 'PUT',
98+
'QUARTER', 'QUERY', 'QUOTE', 'RANGE', 'RAWDEFLATE', 'READ_ONLY', 'RECLUSTER', 'RECORD_DELIMITER',
99+
'REFERENCE_USAGE', 'REFRESH', 'REGEXP', 'RENAME', 'REPLACE', 'RETURN_FAILED_ONLY', 'REVERSE', 'MERGE',
100+
'MATCHED', 'MISSING_FIELD_AS', 'NULL_FIELD_AS', 'UNMATCHED', 'ROW', 'ROWS', 'ROW_TAG', 'GRANT', 'REPEAT',
101+
'ROLE', 'PRECEDING', 'PRECISION', 'PRESIGN', 'PRIVILEGES', 'QUALIFY', 'REMOVE', 'RETAIN', 'REVOKE',
102+
'RECURSIVE', 'RETURN', 'RETURNS', 'RESULTSET', 'RUN', 'GRANTS', 'REFRESH_MODE', 'RIGHT', 'RLIKE', 'RAW',
103+
'OPTIMIZED', 'SCHEMA', 'SCHEMAS', 'SECOND', 'MILLISECOND', 'SELECT', 'PIVOT', 'UNPIVOT', 'SEGMENT',
104+
'SET', 'UNSET', 'SESSION', 'SETTINGS', 'STAGES', 'STATISTIC', 'SUMMARY', 'SHA256_PASSWORD', 'SHOW',
105+
'SINCE', 'SIGNED', 'SINGLE', 'SIZE_LIMIT', 'MAX_FILES', 'SKIP_HEADER', 'SMALLINT', 'SNAPPY', 'SNAPSHOT',
106+
'SPLIT_SIZE', 'STAGE', 'SYNTAX', 'USAGE', 'UPDATE', 'UPLOAD', 'SEQUENCE', 'SHARE', 'SHARES', 'SUPER',
107+
'STATUS', 'STORED', 'STREAM', 'STREAMS', 'STRING', 'SUBSTRING', 'SUBSTR', 'SEMI', 'SOUNDS', 'SYNC',
108+
'SYSTEM', 'STORAGE_TYPE', 'TABLE', 'TABLES', 'TARGET_LAG', 'TEXT', 'LONGTEXT', 'MEDIUMTEXT', 'TINYTEXT',
109+
'TENANTSETTING', 'TENANTS', 'TENANT', 'THEN', 'TIMESTAMP', 'TIMEZONE_HOUR', 'TIMEZONE_MINUTE', 'TIMEZONE',
110+
'TINYINT', 'TO', 'TOKEN', 'TRAILING', 'TRANSIENT', 'TRIM', 'TRUE', 'TRUNCATE', 'TRY_CAST', 'TSV',
111+
'TUPLE', 'TYPE', 'UNBOUNDED', 'UNION', 'UINT16', 'UINT32', 'UINT64', 'UINT8', 'UNDROP', 'UNSIGNED',
112+
'URL', 'METHOD', 'AUTHORIZATION_HEADER', 'USE', 'USER', 'USERS', 'USING', 'VACUUM', 'VALUES',
113+
'VALIDATION_MODE', 'VARBINARY', 'VARCHAR', 'VARIANT', 'VERBOSE', 'VIEW', 'VIEWS', 'VIRTUAL', 'WHEN',
114+
'WHERE', 'WHILE', 'WINDOW', 'WITH', 'XML', 'XOR', 'XZ', 'YEAR', 'ZSTD', 'NULLIF', 'COALESCE', 'RANDOM',
115+
'IFNULL', 'NULLS', 'FIRST', 'LAST', 'IGNORE_RESULT', 'GROUPING', 'SETS', 'CUBE', 'ROLLUP', 'INDEXES',
116+
'ADDRESS', 'OWNERSHIP', 'READ', 'WRITE', 'UDF', 'HANDLER', 'LANGUAGE', 'TASK', 'TASKS', 'TOP',
117+
'WAREHOUSE', 'SCHEDULE', 'SUSPEND_TASK_AFTER_NUM_FAILURES', 'CRON', 'EXECUTE', 'SUSPEND', 'RESUME', 'PIPE',
118+
'NOTIFICATION', 'INTEGRATION', 'ENABLED', 'WEBHOOK', 'ERROR_INTEGRATION', 'AUTO_INGEST',
119+
'PIPE_EXECUTION_PAUSED', 'PREFIX', 'MODIFIED_AFTER', 'UNTIL', 'BEGIN', 'TRANSACTION', 'COMMIT', 'ABORT',
120+
'ROLLBACK', 'TEMPORARY', 'SECONDS', 'DAYS'
121+
}
122+
55123

56124
# Type decorators
57125
class ARRAY(sqltypes.TypeEngine):
@@ -106,6 +174,45 @@ def process(value):
106174

107175
return process
108176

177+
def literal_processor(self, dialect):
178+
def process(value):
179+
if value is not None:
180+
datetime_str = value.isoformat(" ", timespec="microseconds")
181+
return f"'{datetime_str}'"
182+
183+
return process
184+
185+
186+
class DatabendTime(sqltypes.TIME):
187+
__visit_name__ = "TIME"
188+
189+
_reg = re.compile(r"(?:\d+)-(?:\d+)-(?:\d+) (\d+):(\d+):(\d+)")
190+
191+
def result_processor(self, dialect, coltype):
192+
def process(value):
193+
if value is None:
194+
return None
195+
if isinstance(value, str):
196+
m = self._reg.match(value)
197+
if not m:
198+
raise ValueError(
199+
"could not parse %r as a datetime value" % (value,)
200+
)
201+
return datetime.time(*[int(x or 0) for x in m.groups()])
202+
else:
203+
return value.time()
204+
205+
return process
206+
207+
def literal_processor(self, dialect):
208+
def process(value):
209+
if value is not None:
210+
from_min_value = datetime.datetime.combine(datetime.date(1000, 1, 1), value)
211+
time_str = from_min_value.isoformat(timespec="microseconds")
212+
return f"'{time_str}'"
213+
214+
return process
215+
109216

110217
class DatabendNumeric(sqltypes.Numeric):
111218
def result_processor(self, dialect, type_):
@@ -125,6 +232,46 @@ def process(value):
125232
return process
126233

127234

235+
class DatabendInterval(sqltypes.Interval):
236+
"""Stores interval as a datetime relative to epoch, see base implementation."""
237+
238+
_reg = re.compile(r"(\d+)-(\d+)-(\d+) (\d+):(\d+):(\d+)")
239+
240+
def result_processor(self, dialect, coltype):
241+
def process(value):
242+
if value is None:
243+
return None
244+
if isinstance(value, str):
245+
m = self._reg.match(value)
246+
if not m:
247+
raise ValueError(
248+
"could not parse %r as a datetime value" % (value,)
249+
)
250+
groups = m.groups()
251+
dt = datetime.datetime(*[
252+
int(groups[0] or self.epoch.year),
253+
int(groups[1] or self.epoch.month),
254+
int(groups[2] or self.epoch.day),
255+
int(groups[3] or 0),
256+
int(groups[4] or 0),
257+
int(groups[5] or 0),
258+
])
259+
else:
260+
dt = value
261+
return dt - self.epoch
262+
263+
return process
264+
265+
def literal_processor(self, dialect):
266+
def process(value):
267+
if value is not None:
268+
d = self.epoch + value
269+
interval_str = d.isoformat(" ", timespec="microseconds")
270+
return f"'{interval_str}'"
271+
272+
return process
273+
274+
128275
# Type converters
129276
ischema_names = {
130277
"bigint": BIGINT,
@@ -156,10 +303,14 @@ def process(value):
156303
"varchar": VARCHAR,
157304
"boolean": BOOLEAN,
158305
"binary": BINARY,
306+
"time": DatabendTime,
307+
"interval": DatabendInterval,
159308
}
160309

161310
# Column spec
162311
colspecs = {
312+
sqltypes.Interval: DatabendInterval,
313+
sqltypes.Time: DatabendTime,
163314
sqltypes.Date: DatabendDate,
164315
sqltypes.DateTime: DatabendDateTime,
165316
sqltypes.DECIMAL: DatabendNumeric,
@@ -168,7 +319,7 @@ def process(value):
168319

169320

170321
class DatabendIdentifierPreparer(PGIdentifierPreparer):
171-
pass
322+
reserved_words = {r.lower() for r in RESERVED_WORDS}
172323

173324

174325
class DatabendCompiler(PGCompiler):
@@ -211,10 +362,14 @@ def visit_concat_op_binary(self, binary, operator, **kw):
211362

212363
def render_literal_value(self, value, type_):
213364
value = super(DatabendCompiler, self).render_literal_value(value, type_)
214-
if isinstance(type_, sqltypes.DateTime):
215-
value = "toDateTime(%s)" % value
216-
if isinstance(type_, sqltypes.Date):
217-
value = "toDate(%s)" % value
365+
# if isinstance(type_, sqltypes.DateTime):
366+
# return "to_datetime(%s)" % value
367+
# if isinstance(type_, sqltypes.Date):
368+
# return "to_date(%s)" % value
369+
# if isinstance(type_, sqltypes.Time):
370+
# return "to_datetime(%s)" % value
371+
# if isinstance(type_, sqltypes.Interval):
372+
# return "to_datetime(%s)" % value
218373
return value
219374

220375
def limit_clause(self, select, **kw):
@@ -334,6 +489,8 @@ def visit_when_merge_unmatched(self, merge_unmatched, **kw):
334489
", ".join(set_cols),
335490
", ".join(map(lambda e: e._compiler_dispatch(self, **kw), sets_vals)),
336491
)
492+
493+
337494
class DatabendExecutionContext(default.DefaultExecutionContext):
338495
@sa_util.memoized_property
339496
def should_autocommit(self):
@@ -366,6 +523,9 @@ def visit_NVARCHAR(self, type_, **kw):
366523
def visit_JSON(self, type_, **kw):
367524
return "JSON" # or VARIANT
368525

526+
def visit_TIME(self, type_, **kw):
527+
return "DATETIME"
528+
369529

370530
class DatabendDDLCompiler(compiler.DDLCompiler):
371531

@@ -635,9 +795,6 @@ def get_table_names(self, connection, schema=None, **kw):
635795
select table_name
636796
from information_schema.tables
637797
where table_schema = :schema_name
638-
"""
639-
if self.server_version_info <= (1, 2, 410):
640-
table_name_query += """
641798
and engine NOT LIKE '%VIEW%'
642799
"""
643800
query = text(
@@ -654,17 +811,19 @@ def get_table_names(self, connection, schema=None, **kw):
654811
@reflection.cache
655812
def get_view_names(self, connection, schema=None, **kw):
656813
view_name_query = """
657-
select table_name
658-
from information_schema.views
659-
where table_schema = :schema_name
660-
"""
661-
if self.server_version_info <= (1, 2, 410):
662-
view_name_query = """
663814
select table_name
664815
from information_schema.tables
665816
where table_schema = :schema_name
666817
and engine LIKE '%VIEW%'
667-
"""
818+
"""
819+
# This handles bug that existed a while, views were not included in information_schema.tables
820+
# https://github.com/datafuselabs/databend/issues/16039
821+
if self.server_version_info > (1, 2, 410) and self.server_version_info <= (1, 2, 566):
822+
view_name_query = """
823+
select table_name
824+
from information_schema.views
825+
where table_schema = :schema_name
826+
"""
668827
query = text(
669828
view_name_query
670829
).bindparams(
@@ -694,6 +853,13 @@ def get_table_options(self, connection, table_name, schema=None, **kw):
694853
FROM system.tables
695854
WHERE database = :schema_name
696855
and name = :table_name
856+
857+
UNION
858+
859+
SELECT engine_full, NULL as cluster_by, NULL as is_transient
860+
FROM system.views
861+
WHERE database = :schema_name
862+
and name = :table_name
697863
"""
698864
).bindparams(
699865
bindparam("table_name", type_=sqltypes.Unicode),

databend_sqlalchemy/requirements.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ def time(self):
138138
"""target dialect supports representation of Python
139139
datetime.time() objects."""
140140

141-
return exclusions.closed()
141+
return exclusions.open()
142142

143143
@property
144144
def time_microseconds(self):
@@ -147,6 +147,13 @@ def time_microseconds(self):
147147

148148
return exclusions.closed()
149149

150+
@property
151+
def datetime_interval(self):
152+
"""target dialect supports representation of Python
153+
datetime.timedelta()."""
154+
155+
return exclusions.open()
156+
150157
@property
151158
def autoincrement_insert(self):
152159
"""target platform generates new surrogate integer primary key values

tests/test_sqlalchemy.py

+45
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@
1616
from sqlalchemy.testing.suite import JoinTest as _JoinTest
1717
from sqlalchemy.testing.suite import BizarroCharacterFKResolutionTest as _BizarroCharacterFKResolutionTest
1818
from sqlalchemy.testing.suite import ServerSideCursorsTest as _ServerSideCursorsTest
19+
from sqlalchemy.testing.suite import IntervalTest as _IntervalTest
20+
from sqlalchemy.testing.suite import PrecisionIntervalTest as _PrecisionIntervalTest
1921
from sqlalchemy import types as sql_types
2022
from sqlalchemy import testing, select
2123
from sqlalchemy.testing import config, eq_
24+
from databend_sqlalchemy.databend_dialect import DatabendInterval
2225

2326

2427
class ComponentReflectionTest(_ComponentReflectionTest):
@@ -277,3 +280,45 @@ def test_roundtrip_fetchall(self):
277280
@testing.skip("databend") # Skipped because requires auto increment primary key
278281
def test_roundtrip_fetchmany(self):
279282
pass
283+
284+
285+
class IntervalTest(_IntervalTest):
286+
__backend__ = True
287+
datatype = DatabendInterval
288+
289+
@testing.skip("databend") # Skipped because cannot figure out the literal() part
290+
def test_arithmetic_operation_literal_interval(self, connection):
291+
pass
292+
293+
@testing.skip("databend") # Skipped because cannot figure out the literal() part
294+
def test_arithmetic_operation_table_interval_and_literal_interval(
295+
self, connection, arithmetic_table_fixture
296+
):
297+
pass
298+
299+
@testing.skip("databend") # Skipped because cannot figure out the literal() part
300+
def test_arithmetic_operation_table_date_and_literal_interval(
301+
self, connection, arithmetic_table_fixture
302+
):
303+
pass
304+
305+
306+
class PrecisionIntervalTest(_PrecisionIntervalTest):
307+
__backend__ = True
308+
datatype = DatabendInterval
309+
310+
@testing.skip("databend") # Skipped because cannot figure out the literal() part
311+
def test_arithmetic_operation_literal_interval(self, connection):
312+
pass
313+
314+
@testing.skip("databend") # Skipped because cannot figure out the literal() part
315+
def test_arithmetic_operation_table_interval_and_literal_interval(
316+
self, connection, arithmetic_table_fixture
317+
):
318+
pass
319+
320+
@testing.skip("databend") # Skipped because cannot figure out the literal() part
321+
def test_arithmetic_operation_table_date_and_literal_interval(
322+
self, connection, arithmetic_table_fixture
323+
):
324+
pass

0 commit comments

Comments
 (0)