python-mapper 0.3.0__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.
@@ -0,0 +1,139 @@
1
+ """PostgreSQL XML mapper with an asyncpg pool and implicit transactions."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Sequence
5
+ from pathlib import Path
6
+
7
+ from python_mapper import runtime
8
+ from python_mapper.base import (
9
+ MapperBase,
10
+ bind_connection,
11
+ current_connection,
12
+ require_connection,
13
+ transactional,
14
+ transactional_scope,
15
+ )
16
+ from python_mapper.database import (
17
+ ConnectionLike,
18
+ PoolLike,
19
+ acquire_raw_connection,
20
+ close_database,
21
+ configure_database,
22
+ configure_pool,
23
+ get_pool,
24
+ open_database,
25
+ ping_database,
26
+ )
27
+ from python_mapper.errors import (
28
+ PaginationConflictError,
29
+ PaginationError,
30
+ PyMapperError,
31
+ TooManyResultsError,
32
+ )
33
+ from python_mapper.extension import MapperStartupState, PyMapperExtension
34
+ from python_mapper.observability import SqlLoggingPlugin
35
+ from python_mapper.pagination import (
36
+ DEFAULT_PAGE_SIZE,
37
+ MAX_PAGE_SIZE,
38
+ Page,
39
+ PageMetadata,
40
+ PaginationOptions,
41
+ QueryResult,
42
+ )
43
+ from python_mapper.plugins import (
44
+ StatementContext,
45
+ StatementExecutor,
46
+ StatementPlugin,
47
+ StatementResult,
48
+ )
49
+ from python_mapper.runtime import (
50
+ AMapper,
51
+ amapper,
52
+ configure_mapper_paths,
53
+ configure_plugins,
54
+ load_all_mappers,
55
+ load_mapper,
56
+ query,
57
+ render_sql,
58
+ reset_state,
59
+ scalar,
60
+ validate_result_types,
61
+ )
62
+
63
+ __version__ = "0.3.0"
64
+
65
+
66
+ def configure(
67
+ *,
68
+ mapper_paths: Sequence[str | Path],
69
+ database_url: str | None = None,
70
+ pool: PoolLike | None = None,
71
+ min_pool_size: int = 1,
72
+ max_pool_size: int = 10,
73
+ command_timeout: float | None = None,
74
+ ssl=None,
75
+ statement_cache_size: int = 100,
76
+ plugins: Sequence[StatementPlugin] = (),
77
+ ) -> None:
78
+ """Bind one database backend and the application's XML roots."""
79
+ if (database_url is None) == (pool is None):
80
+ raise ValueError("configure requires exactly one of database_url or pool")
81
+ if pool is not None:
82
+ configure_pool(pool)
83
+ else:
84
+ configure_database(
85
+ dsn=database_url or "",
86
+ min_size=min_pool_size,
87
+ max_size=max_pool_size,
88
+ command_timeout=command_timeout,
89
+ ssl=ssl,
90
+ statement_cache_size=statement_cache_size,
91
+ )
92
+ configure_mapper_paths(tuple(mapper_paths))
93
+ configure_plugins(tuple(plugins))
94
+
95
+
96
+ __all__ = [
97
+ "AMapper",
98
+ "ConnectionLike",
99
+ "DEFAULT_PAGE_SIZE",
100
+ "MAX_PAGE_SIZE",
101
+ "MapperBase",
102
+ "MapperStartupState",
103
+ "Page",
104
+ "PageMetadata",
105
+ "PaginationConflictError",
106
+ "PaginationError",
107
+ "PaginationOptions",
108
+ "PoolLike",
109
+ "PyMapperError",
110
+ "PyMapperExtension",
111
+ "QueryResult",
112
+ "SqlLoggingPlugin",
113
+ "StatementContext",
114
+ "StatementExecutor",
115
+ "StatementPlugin",
116
+ "StatementResult",
117
+ "TooManyResultsError",
118
+ "__version__",
119
+ "amapper",
120
+ "acquire_raw_connection",
121
+ "bind_connection",
122
+ "close_database",
123
+ "configure",
124
+ "current_connection",
125
+ "get_pool",
126
+ "load_all_mappers",
127
+ "load_mapper",
128
+ "render_sql",
129
+ "open_database",
130
+ "ping_database",
131
+ "query",
132
+ "require_connection",
133
+ "reset_state",
134
+ "runtime",
135
+ "scalar",
136
+ "transactional",
137
+ "transactional_scope",
138
+ "validate_result_types",
139
+ ]
python_mapper/base.py ADDED
@@ -0,0 +1,176 @@
1
+ """Implicit asyncpg connection and explicit transaction boundaries."""
2
+ from __future__ import annotations
3
+
4
+ import functools
5
+ import inspect
6
+ from collections.abc import AsyncGenerator, Awaitable, Callable
7
+ from contextlib import asynccontextmanager
8
+ from contextvars import ContextVar, Token
9
+ from typing import ParamSpec, TypeVar
10
+
11
+ from python_mapper.database import ConnectionLike, acquire_raw_connection
12
+
13
+ CallableParameters = ParamSpec("CallableParameters")
14
+ ReturnValue = TypeVar("ReturnValue")
15
+
16
+ _ACTIVE_CONNECTION: ContextVar[ConnectionLike | None] = ContextVar(
17
+ "mapper_active_connection", default=None
18
+ )
19
+ _ACTIVE_TRANSACTION_OPTIONS: ContextVar[tuple[str | None, bool] | None] = ContextVar(
20
+ "mapper_active_transaction_options", default=None
21
+ )
22
+ _VALID_ISOLATION_LEVELS = {"READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE"}
23
+
24
+
25
+ def _normalize_isolation_level(value: str | None) -> str | None:
26
+ if value is None:
27
+ return None
28
+ normalized = value.strip().upper().replace("_", " ")
29
+ if normalized not in _VALID_ISOLATION_LEVELS:
30
+ raise ValueError(
31
+ "isolation_level must be READ COMMITTED, REPEATABLE READ or SERIALIZABLE"
32
+ )
33
+ return normalized
34
+
35
+
36
+ def _asyncpg_isolation(value: str | None) -> str | None:
37
+ return value.lower().replace(" ", "_") if value is not None else None
38
+
39
+
40
+ class MapperBase:
41
+ """Base contract for mappers that consume an implicit asyncpg connection."""
42
+
43
+ @classmethod
44
+ @asynccontextmanager
45
+ async def acquire_connection(cls) -> AsyncGenerator[ConnectionLike]:
46
+ """Reuse an active transaction or borrow a connection for one statement."""
47
+ current = _ACTIVE_CONNECTION.get()
48
+ if current is not None:
49
+ yield current
50
+ return
51
+ async with acquire_raw_connection() as connection:
52
+ yield connection
53
+
54
+ @classmethod
55
+ @asynccontextmanager
56
+ async def transaction_scope(
57
+ cls,
58
+ *,
59
+ requires_new: bool = False,
60
+ isolation_level: str | None = None,
61
+ read_only: bool = False,
62
+ ) -> AsyncGenerator[ConnectionLike]:
63
+ """Open one transaction, reusing the current one for REQUIRED semantics."""
64
+ normalized_isolation = _normalize_isolation_level(isolation_level)
65
+ current = _ACTIVE_CONNECTION.get()
66
+ if current is not None and not requires_new:
67
+ active_options = _ACTIVE_TRANSACTION_OPTIONS.get()
68
+ if (
69
+ normalized_isolation is not None
70
+ and active_options is not None
71
+ and active_options[0] != normalized_isolation
72
+ ):
73
+ raise RuntimeError(
74
+ "cannot change transaction isolation while joining an active transaction"
75
+ )
76
+ if read_only and active_options is not None and not active_options[1]:
77
+ raise RuntimeError("cannot join a read-write transaction as read-only")
78
+ yield current
79
+ return
80
+
81
+ async with acquire_raw_connection() as connection:
82
+ connection_token = _ACTIVE_CONNECTION.set(connection)
83
+ options_token = _ACTIVE_TRANSACTION_OPTIONS.set(
84
+ (normalized_isolation, read_only)
85
+ )
86
+ try:
87
+ async with connection.transaction(
88
+ isolation=_asyncpg_isolation(normalized_isolation),
89
+ readonly=read_only,
90
+ ):
91
+ yield connection
92
+ finally:
93
+ _ACTIVE_TRANSACTION_OPTIONS.reset(options_token)
94
+ _ACTIVE_CONNECTION.reset(connection_token)
95
+
96
+
97
+ def current_connection() -> ConnectionLike | None:
98
+ return _ACTIVE_CONNECTION.get()
99
+
100
+
101
+ def require_connection() -> ConnectionLike:
102
+ connection = current_connection()
103
+ if connection is None:
104
+ raise RuntimeError("当前调用不在 pymapper 事务上下文中")
105
+ return connection
106
+
107
+
108
+ @asynccontextmanager
109
+ async def bind_connection(connection: ConnectionLike) -> AsyncGenerator[ConnectionLike]:
110
+ """Borrow an externally managed connection without commit, rollback or close."""
111
+ connection_token: Token[ConnectionLike | None] = _ACTIVE_CONNECTION.set(connection)
112
+ options_token = _ACTIVE_TRANSACTION_OPTIONS.set((None, False))
113
+ try:
114
+ yield connection
115
+ finally:
116
+ _ACTIVE_TRANSACTION_OPTIONS.reset(options_token)
117
+ _ACTIVE_CONNECTION.reset(connection_token)
118
+
119
+
120
+ def transactional(
121
+ *,
122
+ propagation: str = "REQUIRED",
123
+ isolation_level: str | None = None,
124
+ read_only: bool = False,
125
+ ):
126
+ """Wrap an async service method in an asyncpg transaction.
127
+
128
+ ``REQUIRED`` joins the task-local transaction. ``REQUIRES_NEW`` suspends it
129
+ and borrows another connection for an independent transaction.
130
+
131
+ Do not create child tasks that call mappers inside this boundary. ContextVars
132
+ are copied into child tasks, which would make concurrent tasks share one
133
+ asyncpg connection and fail with an InterfaceError or use it after release.
134
+ """
135
+ normalized = propagation.strip().upper()
136
+ if normalized not in {"REQUIRED", "REQUIRES_NEW"}:
137
+ raise ValueError("propagation must be REQUIRED or REQUIRES_NEW")
138
+ normalized_isolation = _normalize_isolation_level(isolation_level)
139
+
140
+ def decorator(
141
+ func: Callable[CallableParameters, Awaitable[ReturnValue]],
142
+ ) -> Callable[CallableParameters, Awaitable[ReturnValue]]:
143
+ if not inspect.iscoroutinefunction(func):
144
+ raise TypeError(
145
+ f"transactional 只能装饰 async 函数, {getattr(func, '__qualname__', func)!r} "
146
+ "是同步的"
147
+ )
148
+
149
+ @functools.wraps(func)
150
+ async def wrapper(
151
+ *args: CallableParameters.args,
152
+ **kwargs: CallableParameters.kwargs,
153
+ ) -> ReturnValue:
154
+ async with MapperBase.transaction_scope(
155
+ requires_new=normalized == "REQUIRES_NEW",
156
+ isolation_level=normalized_isolation,
157
+ read_only=read_only,
158
+ ):
159
+ return await func(*args, **kwargs)
160
+
161
+ return wrapper
162
+
163
+ return decorator
164
+
165
+
166
+ transactional_scope = MapperBase.transaction_scope
167
+
168
+
169
+ __all__ = [
170
+ "MapperBase",
171
+ "bind_connection",
172
+ "current_connection",
173
+ "require_connection",
174
+ "transactional",
175
+ "transactional_scope",
176
+ ]
@@ -0,0 +1,365 @@
1
+ """Compile named XML parameters into asyncpg positional parameters."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class CompiledQuery:
10
+ sql: str
11
+ args: tuple[Any, ...]
12
+
13
+
14
+ def _is_identifier_start(character: str) -> bool:
15
+ return character == "_" or character.isalpha()
16
+
17
+
18
+ def _is_identifier_part(character: str) -> bool:
19
+ return character == "_" or character.isalnum()
20
+
21
+
22
+ def _is_postgresql_identifier_part(character: str) -> bool:
23
+ """PostgreSQL permits dollar signs after the first identifier character."""
24
+ return character == "$" or _is_identifier_part(character)
25
+
26
+
27
+ def _is_collection_parameter(value: Any) -> bool:
28
+ return isinstance(value, (list, tuple, set, frozenset))
29
+
30
+
31
+ def _dollar_quote_delimiter(sql: str, offset: int) -> str | None:
32
+ if sql[offset] != "$":
33
+ return None
34
+ if offset > 0 and _is_postgresql_identifier_part(sql[offset - 1]):
35
+ return None
36
+ end = sql.find("$", offset + 1)
37
+ if end < 0:
38
+ return None
39
+ tag = sql[offset + 1:end]
40
+ if tag and (not _is_identifier_start(tag[0]) or not all(_is_identifier_part(c) for c in tag)):
41
+ return None
42
+ return sql[offset:end + 1]
43
+
44
+
45
+ def _skip_single_quoted_string(sql: str, quote_offset: int) -> int:
46
+ """Return the first offset after a regular or PostgreSQL E-string."""
47
+ prefix_offset = quote_offset - 1
48
+ uses_backslash_escapes = (
49
+ prefix_offset >= 0
50
+ and sql[prefix_offset] in {"E", "e"}
51
+ and (
52
+ prefix_offset == 0
53
+ or not _is_postgresql_identifier_part(sql[prefix_offset - 1])
54
+ )
55
+ )
56
+ index = quote_offset + 1
57
+ length = len(sql)
58
+ while index < length:
59
+ if uses_backslash_escapes and sql[index] == "\\":
60
+ index = min(length, index + 2)
61
+ continue
62
+ if sql[index] == "'":
63
+ if index + 1 < length and sql[index + 1] == "'":
64
+ index += 2
65
+ continue
66
+ return index + 1
67
+ index += 1
68
+ return length
69
+
70
+
71
+ def _scan_parameters(
72
+ sql: str,
73
+ ) -> tuple[list[tuple[int, int, str]], list[tuple[int, int, int]]]:
74
+ """Find named and native positional binds outside non-executable regions."""
75
+ named: list[tuple[int, int, str]] = []
76
+ positional: list[tuple[int, int, int]] = []
77
+ index = 0
78
+ length = len(sql)
79
+ while index < length:
80
+ character = sql[index]
81
+ if character == "'":
82
+ index = _skip_single_quoted_string(sql, index)
83
+ continue
84
+ if character == '"':
85
+ index += 1
86
+ while index < length:
87
+ if sql[index] == '"':
88
+ if index + 1 < length and sql[index + 1] == '"':
89
+ index += 2
90
+ continue
91
+ index += 1
92
+ break
93
+ index += 1
94
+ continue
95
+ if sql.startswith("--", index):
96
+ newline = sql.find("\n", index + 2)
97
+ index = length if newline < 0 else newline + 1
98
+ continue
99
+ if sql.startswith("/*", index):
100
+ depth = 1
101
+ index += 2
102
+ while index < length and depth:
103
+ if sql.startswith("/*", index):
104
+ depth += 1
105
+ index += 2
106
+ elif sql.startswith("*/", index):
107
+ depth -= 1
108
+ index += 2
109
+ else:
110
+ index += 1
111
+ continue
112
+ delimiter = _dollar_quote_delimiter(sql, index) if character == "$" else None
113
+ if delimiter is not None:
114
+ end = sql.find(delimiter, index + len(delimiter))
115
+ index = length if end < 0 else end + len(delimiter)
116
+ continue
117
+ if character == "$" and index + 1 < length and sql[index + 1].isdigit():
118
+ position_end = index + 2
119
+ while position_end < length and sql[position_end].isdigit():
120
+ position_end += 1
121
+ positional.append((index, position_end, int(sql[index + 1:position_end])))
122
+ index = position_end
123
+ continue
124
+ if (
125
+ character == ":"
126
+ and not sql.startswith("::", index)
127
+ and (index == 0 or sql[index - 1] != ":")
128
+ ):
129
+ name_start = index + 1
130
+ if name_start < length and _is_identifier_start(sql[name_start]):
131
+ name_end = name_start + 1
132
+ while name_end < length and _is_identifier_part(sql[name_end]):
133
+ name_end += 1
134
+ named.append((index, name_end, sql[name_start:name_end]))
135
+ index = name_end
136
+ continue
137
+ index += 1
138
+ return named, positional
139
+
140
+
141
+ def _scan_named_parameters(sql: str) -> list[tuple[int, int, str]]:
142
+ return _scan_parameters(sql)[0]
143
+
144
+
145
+ def named_parameter_names(sql: str) -> set[str]:
146
+ return {name for _, _, name in _scan_named_parameters(sql)}
147
+
148
+
149
+ def positional_parameter_numbers(sql: str) -> set[int]:
150
+ """Return native asyncpg ``$n`` binds outside literals and comments."""
151
+ return {number for _, _, number in _scan_parameters(sql)[1]}
152
+
153
+
154
+ def sql_token_parenthesis_depths(sql: str, token: str) -> tuple[int, ...]:
155
+ """Return parenthesis depths for an exact token in executable SQL regions."""
156
+ if not token:
157
+ raise ValueError("token must not be empty")
158
+ depths: list[int] = []
159
+ index = 0
160
+ depth = 0
161
+ length = len(sql)
162
+ while index < length:
163
+ if sql.startswith(token, index):
164
+ depths.append(depth)
165
+ index += len(token)
166
+ continue
167
+ character = sql[index]
168
+ if character == "'":
169
+ index = _skip_single_quoted_string(sql, index)
170
+ continue
171
+ if character == '"':
172
+ index += 1
173
+ while index < length:
174
+ if sql[index] == '"':
175
+ if index + 1 < length and sql[index + 1] == '"':
176
+ index += 2
177
+ continue
178
+ index += 1
179
+ break
180
+ index += 1
181
+ continue
182
+ if sql.startswith("--", index):
183
+ newline = sql.find("\n", index + 2)
184
+ index = length if newline < 0 else newline + 1
185
+ continue
186
+ if sql.startswith("/*", index):
187
+ comment_depth = 1
188
+ index += 2
189
+ while index < length and comment_depth:
190
+ if sql.startswith("/*", index):
191
+ comment_depth += 1
192
+ index += 2
193
+ elif sql.startswith("*/", index):
194
+ comment_depth -= 1
195
+ index += 2
196
+ else:
197
+ index += 1
198
+ continue
199
+ delimiter = _dollar_quote_delimiter(sql, index) if character == "$" else None
200
+ if delimiter is not None:
201
+ end = sql.find(delimiter, index + len(delimiter))
202
+ index = length if end < 0 else end + len(delimiter)
203
+ continue
204
+ if character == "(":
205
+ depth += 1
206
+ elif character == ")" and depth:
207
+ depth -= 1
208
+ index += 1
209
+ return tuple(depths)
210
+
211
+
212
+ def compile_query(sql: str, parameters: dict[str, Any]) -> CompiledQuery:
213
+ """Compile ``:name`` parameters and SQLAlchemy-style collection expansion."""
214
+ occurrences = _scan_named_parameters(sql)
215
+ missing = sorted({name for _, _, name in occurrences if name not in parameters})
216
+ if missing:
217
+ raise ValueError(f"SQL 缺少绑定参数: {missing}")
218
+
219
+ args: list[Any] = []
220
+ placeholders: dict[str, str] = {}
221
+ output: list[str] = []
222
+ cursor = 0
223
+ for start, end, name in occurrences:
224
+ output.append(sql[cursor:start])
225
+ placeholder = placeholders.get(name)
226
+ if placeholder is None:
227
+ value = parameters[name]
228
+ if _is_collection_parameter(value):
229
+ values = list(value)
230
+ if values:
231
+ positions = []
232
+ for item in values:
233
+ args.append(item)
234
+ positions.append(f"${len(args)}")
235
+ placeholder = f"({', '.join(positions)})"
236
+ else:
237
+ placeholder = "(SELECT NULL WHERE FALSE)"
238
+ else:
239
+ args.append(value)
240
+ placeholder = f"${len(args)}"
241
+ placeholders[name] = placeholder
242
+ output.append(placeholder)
243
+ cursor = end
244
+ output.append(sql[cursor:])
245
+ return CompiledQuery("".join(output), tuple(args))
246
+
247
+
248
+ def contains_sql_keyword(sql: str, keyword: str) -> bool:
249
+ """Check a keyword outside SQL literals and comments."""
250
+ import re
251
+
252
+ executable = []
253
+ cursor = 0
254
+ for start, end, _ in _scan_named_parameters(sql):
255
+ executable.append(sql[cursor:start])
256
+ executable.append(" ")
257
+ cursor = end
258
+ executable.append(sql[cursor:])
259
+ text = "".join(executable)
260
+ text = re.sub(r"'(?:''|[^'])*'", " ", text)
261
+ text = re.sub(r'"(?:""|[^"])*"', " ", text)
262
+ text = re.sub(r"--[^\n]*", " ", text)
263
+ text = re.sub(r"/\*.*?\*/", " ", text, flags=re.S)
264
+ return re.search(rf"\b{re.escape(keyword)}\b", text, re.IGNORECASE) is not None
265
+
266
+
267
+ def top_level_sql_word_positions(sql: str) -> tuple[tuple[str, int], ...]:
268
+ """Return executable words and offsets at parenthesis depth zero.
269
+
270
+ Strings, quoted identifiers, comments and PostgreSQL dollar-quoted bodies are
271
+ skipped. This is deliberately a lexer, not a SQL parser; it is sufficient for
272
+ detecting outer LIMIT/OFFSET/ORDER BY without rejecting limits in subqueries.
273
+ """
274
+ words: list[tuple[str, int]] = []
275
+ index = 0
276
+ depth = 0
277
+ length = len(sql)
278
+ while index < length:
279
+ character = sql[index]
280
+ if character == "'":
281
+ index = _skip_single_quoted_string(sql, index)
282
+ continue
283
+ if character == '"':
284
+ index += 1
285
+ while index < length:
286
+ if sql[index] == '"':
287
+ if index + 1 < length and sql[index + 1] == '"':
288
+ index += 2
289
+ continue
290
+ index += 1
291
+ break
292
+ index += 1
293
+ continue
294
+ if sql.startswith("--", index):
295
+ newline = sql.find("\n", index + 2)
296
+ index = length if newline < 0 else newline + 1
297
+ continue
298
+ if sql.startswith("/*", index):
299
+ comment_depth = 1
300
+ index += 2
301
+ while index < length and comment_depth:
302
+ if sql.startswith("/*", index):
303
+ comment_depth += 1
304
+ index += 2
305
+ elif sql.startswith("*/", index):
306
+ comment_depth -= 1
307
+ index += 2
308
+ else:
309
+ index += 1
310
+ continue
311
+ delimiter = _dollar_quote_delimiter(sql, index) if character == "$" else None
312
+ if delimiter is not None:
313
+ end = sql.find(delimiter, index + len(delimiter))
314
+ index = length if end < 0 else end + len(delimiter)
315
+ continue
316
+ if character == "(":
317
+ depth += 1
318
+ index += 1
319
+ continue
320
+ if character == ")":
321
+ depth = max(0, depth - 1)
322
+ index += 1
323
+ continue
324
+ if _is_identifier_start(character):
325
+ end = index + 1
326
+ while end < length and _is_postgresql_identifier_part(sql[end]):
327
+ end += 1
328
+ if depth == 0:
329
+ words.append((sql[index:end].upper(), index))
330
+ index = end
331
+ continue
332
+ index += 1
333
+ return tuple(words)
334
+
335
+
336
+ def top_level_sql_words(sql: str) -> tuple[str, ...]:
337
+ """Return executable words at parenthesis depth zero."""
338
+ return tuple(word for word, _ in top_level_sql_word_positions(sql))
339
+
340
+
341
+ def contains_top_level_keyword(sql: str, keyword: str) -> bool:
342
+ return keyword.upper() in top_level_sql_words(sql)
343
+
344
+
345
+ def contains_top_level_sequence(sql: str, *keywords: str) -> bool:
346
+ expected = tuple(keyword.upper() for keyword in keywords)
347
+ if not expected:
348
+ return False
349
+ words = top_level_sql_words(sql)
350
+ width = len(expected)
351
+ return any(words[index:index + width] == expected for index in range(len(words) - width + 1))
352
+
353
+
354
+ __all__ = [
355
+ "CompiledQuery",
356
+ "compile_query",
357
+ "contains_sql_keyword",
358
+ "contains_top_level_keyword",
359
+ "contains_top_level_sequence",
360
+ "named_parameter_names",
361
+ "positional_parameter_numbers",
362
+ "sql_token_parenthesis_depths",
363
+ "top_level_sql_word_positions",
364
+ "top_level_sql_words",
365
+ ]