xbot.plugins.pgsql 0.1.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.
- tests/__init__.py +0 -0
- tests/run.py +36 -0
- tests/test_pgsql.py +254 -0
- xbot/plugins/pgsql/__init__.py +17 -0
- xbot/plugins/pgsql/codes.py +363 -0
- xbot/plugins/pgsql/errors.py +44 -0
- xbot/plugins/pgsql/pgsql.py +213 -0
- xbot/plugins/pgsql/py.typed +0 -0
- xbot/plugins/pgsql/version.py +5 -0
- xbot_plugins_pgsql-0.1.0.dist-info/METADATA +134 -0
- xbot_plugins_pgsql-0.1.0.dist-info/RECORD +14 -0
- xbot_plugins_pgsql-0.1.0.dist-info/WHEEL +5 -0
- xbot_plugins_pgsql-0.1.0.dist-info/licenses/LICENSE +24 -0
- xbot_plugins_pgsql-0.1.0.dist-info/top_level.txt +2 -0
tests/__init__.py
ADDED
|
File without changes
|
tests/run.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Run tests against a caller-provided PostgreSQL server.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from test_pgsql import TestPGSQLConnection
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def create_parser() -> argparse.ArgumentParser:
|
|
13
|
+
parser = argparse.ArgumentParser()
|
|
14
|
+
parser.add_argument("-H", "--host", required=True)
|
|
15
|
+
parser.add_argument("-P", "--port", type=int, default=5432)
|
|
16
|
+
parser.add_argument("-u", "--user", required=True)
|
|
17
|
+
parser.add_argument("-p", "--password", required=True)
|
|
18
|
+
parser.add_argument("-d", "--database", required=True)
|
|
19
|
+
return parser
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main() -> int:
|
|
23
|
+
args = create_parser().parse_args()
|
|
24
|
+
TestPGSQLConnection.HOST = args.host
|
|
25
|
+
TestPGSQLConnection.PORT = args.port
|
|
26
|
+
TestPGSQLConnection.USER = args.user
|
|
27
|
+
TestPGSQLConnection.PASSWORD = args.password
|
|
28
|
+
TestPGSQLConnection.DATABASE = args.database
|
|
29
|
+
|
|
30
|
+
suite = unittest.TestLoader().discover(str(Path(__file__).parent))
|
|
31
|
+
result = unittest.TextTestRunner(verbosity=2).run(suite)
|
|
32
|
+
return 0 if result.wasSuccessful() else 1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
raise SystemExit(main())
|
tests/test_pgsql.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
|
|
7
|
+
from xbot.plugins.pgsql import PGCodes
|
|
8
|
+
from xbot.plugins.pgsql import PGSQLCommandError
|
|
9
|
+
from xbot.plugins.pgsql import PGSQLCommandResult
|
|
10
|
+
from xbot.plugins.pgsql import PGSQLConnectError
|
|
11
|
+
from xbot.plugins.pgsql import PGSQLConnection
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TestPGSQLCommandResult(unittest.TestCase):
|
|
15
|
+
def test_is_list_with_metadata(self) -> None:
|
|
16
|
+
result = PGSQLCommandResult(
|
|
17
|
+
[(1, 'alice'), (2, 'bob')],
|
|
18
|
+
header=('id', 'name'),
|
|
19
|
+
rc='00000',
|
|
20
|
+
cmd='SELECT id, name FROM users ORDER BY id',
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
self.assertIsInstance(result, list)
|
|
24
|
+
self.assertEqual(result, [(1, 'alice'), (2, 'bob')])
|
|
25
|
+
self.assertEqual(result.header, ('id', 'name'))
|
|
26
|
+
self.assertEqual(result.rc, '00000')
|
|
27
|
+
self.assertEqual(result.cmd, 'SELECT id, name FROM users ORDER BY id')
|
|
28
|
+
self.assertEqual(repr(result), "[(1, 'alice'), (2, 'bob')]")
|
|
29
|
+
|
|
30
|
+
def test_str_is_live_psql_table_with_header(self) -> None:
|
|
31
|
+
result = PGSQLCommandResult(
|
|
32
|
+
[(1, 'alice')],
|
|
33
|
+
header=('id', 'name'),
|
|
34
|
+
rc='00000',
|
|
35
|
+
cmd="SELECT 1, 'alice'",
|
|
36
|
+
)
|
|
37
|
+
first_render = str(result)
|
|
38
|
+
self.assertIn('id', first_render)
|
|
39
|
+
self.assertIn('name', first_render)
|
|
40
|
+
self.assertIn('alice', first_render)
|
|
41
|
+
|
|
42
|
+
result.append((2, 'bob'))
|
|
43
|
+
self.assertIn('bob', str(result))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class TestPGSQLConnection(unittest.TestCase):
|
|
47
|
+
HOST: ClassVar[str] = ''
|
|
48
|
+
PORT: ClassVar[int] = 5432
|
|
49
|
+
USER: ClassVar[str] = ''
|
|
50
|
+
PASSWORD: ClassVar[str] = ''
|
|
51
|
+
DATABASE: ClassVar[str] = ''
|
|
52
|
+
connection: ClassVar[PGSQLConnection]
|
|
53
|
+
schema: ClassVar[str] = f'xbot_pgsql_{uuid.uuid4().hex}'
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def setUpClass(cls) -> None:
|
|
57
|
+
cls.connection = cls.new_connection()
|
|
58
|
+
cls.connection.exec(f'CREATE SCHEMA "{cls.schema}"')
|
|
59
|
+
cls.connection.exec(f'SET search_path TO "{cls.schema}"')
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def tearDownClass(cls) -> None:
|
|
63
|
+
if cls.connection is not None:
|
|
64
|
+
cls.connection.exec(
|
|
65
|
+
f'DROP SCHEMA IF EXISTS "{cls.schema}" CASCADE',
|
|
66
|
+
expect=None,
|
|
67
|
+
)
|
|
68
|
+
cls.connection.disconnect()
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def new_connection(cls) -> PGSQLConnection:
|
|
72
|
+
connection = PGSQLConnection()
|
|
73
|
+
connection.connect(
|
|
74
|
+
cls.HOST,
|
|
75
|
+
cls.USER,
|
|
76
|
+
cls.PASSWORD,
|
|
77
|
+
cls.DATABASE,
|
|
78
|
+
cls.PORT,
|
|
79
|
+
)
|
|
80
|
+
return connection
|
|
81
|
+
|
|
82
|
+
def test_connect_is_idempotent(self) -> None:
|
|
83
|
+
connection = self.new_connection()
|
|
84
|
+
try:
|
|
85
|
+
first = connection._conn
|
|
86
|
+
connection.connect(
|
|
87
|
+
self.HOST,
|
|
88
|
+
self.USER,
|
|
89
|
+
self.PASSWORD,
|
|
90
|
+
self.DATABASE,
|
|
91
|
+
self.PORT,
|
|
92
|
+
)
|
|
93
|
+
self.assertIs(connection._conn, first)
|
|
94
|
+
finally:
|
|
95
|
+
connection.disconnect()
|
|
96
|
+
|
|
97
|
+
def test_disconnect_is_idempotent(self) -> None:
|
|
98
|
+
connection = self.new_connection()
|
|
99
|
+
connection.disconnect()
|
|
100
|
+
connection.disconnect()
|
|
101
|
+
|
|
102
|
+
def test_invalid_database_raises_connect_error(self) -> None:
|
|
103
|
+
connection = PGSQLConnection()
|
|
104
|
+
invalid_database = f'xbot_missing_{uuid.uuid4().hex}'
|
|
105
|
+
with self.assertRaises(PGSQLConnectError) as caught:
|
|
106
|
+
connection.connect(
|
|
107
|
+
self.HOST,
|
|
108
|
+
self.USER,
|
|
109
|
+
self.PASSWORD,
|
|
110
|
+
invalid_database,
|
|
111
|
+
self.PORT,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def test_exec_select_returns_rows_and_header(self) -> None:
|
|
115
|
+
result = self.connection.exec(
|
|
116
|
+
"SELECT 1::integer AS id, 'alice'::text AS name"
|
|
117
|
+
)
|
|
118
|
+
self.assertEqual(result, [(1, 'alice')])
|
|
119
|
+
self.assertEqual(result.header, ('id', 'name'))
|
|
120
|
+
self.assertEqual(result.rc, '00000')
|
|
121
|
+
self.assertIn('alice', str(result))
|
|
122
|
+
|
|
123
|
+
def test_exec_ddl_dml_returning(self) -> None:
|
|
124
|
+
table = f'items_{uuid.uuid4().hex}'
|
|
125
|
+
ddl = self.connection.exec(
|
|
126
|
+
f'CREATE TABLE "{table}" (id integer, name text)'
|
|
127
|
+
)
|
|
128
|
+
self.assertEqual(ddl, [])
|
|
129
|
+
self.assertEqual(ddl.header, ())
|
|
130
|
+
|
|
131
|
+
inserted = self.connection.exec(
|
|
132
|
+
f"""INSERT INTO "{table}" VALUES (1, 'alice') RETURNING id, name""",
|
|
133
|
+
)
|
|
134
|
+
self.assertEqual(inserted, [(1, 'alice')])
|
|
135
|
+
self.assertEqual(inserted.header, ('id', 'name'))
|
|
136
|
+
|
|
137
|
+
def test_exec_expected_sqlstate_returns_result(self) -> None:
|
|
138
|
+
result = self.connection.exec(
|
|
139
|
+
'SELECT * FROM missing_table_expected',
|
|
140
|
+
expect=PGCodes.UNDEFINED_TABLE,
|
|
141
|
+
)
|
|
142
|
+
self.assertEqual(result, [])
|
|
143
|
+
self.assertEqual(result.header, ())
|
|
144
|
+
self.assertEqual(result.rc, '42P01')
|
|
145
|
+
|
|
146
|
+
def test_exec_none_expect_accepts_success_and_failure(self) -> None:
|
|
147
|
+
success = self.connection.exec('SELECT 1', expect=None)
|
|
148
|
+
failure = self.connection.exec(
|
|
149
|
+
'SELECT * FROM missing_table_none',
|
|
150
|
+
expect=None,
|
|
151
|
+
)
|
|
152
|
+
self.assertEqual(success.rc, '00000')
|
|
153
|
+
self.assertEqual(failure.rc, '42P01')
|
|
154
|
+
|
|
155
|
+
def test_exec_default_expect_raises_on_error(self) -> None:
|
|
156
|
+
with self.assertRaises(PGSQLCommandError) as caught:
|
|
157
|
+
self.connection.exec('SELECT * FROM missing_table_default')
|
|
158
|
+
|
|
159
|
+
self.assertEqual(caught.exception.result.rc, '42P01')
|
|
160
|
+
self.assertEqual(caught.exception.expect, PGCodes.SUCCESSFUL_COMPLETION)
|
|
161
|
+
|
|
162
|
+
def test_exec_mismatched_expect_raises(self) -> None:
|
|
163
|
+
with self.assertRaises(PGSQLCommandError) as caught:
|
|
164
|
+
self.connection.exec(
|
|
165
|
+
'SELECT 1',
|
|
166
|
+
expect=PGCodes.UNDEFINED_TABLE,
|
|
167
|
+
)
|
|
168
|
+
self.assertEqual(caught.exception.result.rc, '00000')
|
|
169
|
+
self.assertIs(caught.exception.expect, PGCodes.UNDEFINED_TABLE)
|
|
170
|
+
|
|
171
|
+
def test_exec_multiple_statements_returns_syntax_error(self) -> None:
|
|
172
|
+
result = self.connection.exec(
|
|
173
|
+
'SELECT 1; SELECT 2',
|
|
174
|
+
expect=PGCodes.SYNTAX_ERROR,
|
|
175
|
+
)
|
|
176
|
+
self.assertEqual(result.rc, '42601')
|
|
177
|
+
|
|
178
|
+
def test_transaction_commits(self) -> None:
|
|
179
|
+
table = f'tx_commit_{uuid.uuid4().hex}'
|
|
180
|
+
self.connection.exec(
|
|
181
|
+
f'CREATE TABLE "{table}" (id integer PRIMARY KEY)'
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
with self.connection.transaction():
|
|
185
|
+
self.connection.exec(f'INSERT INTO "{table}" VALUES (1)')
|
|
186
|
+
|
|
187
|
+
rows = self.connection.exec(
|
|
188
|
+
f'SELECT id FROM "{table}" ORDER BY id'
|
|
189
|
+
)
|
|
190
|
+
self.assertEqual(rows, [(1,)])
|
|
191
|
+
|
|
192
|
+
def test_transaction_rolls_back_on_exception(self) -> None:
|
|
193
|
+
table = f'tx_rollback_{uuid.uuid4().hex}'
|
|
194
|
+
self.connection.exec(
|
|
195
|
+
f'CREATE TABLE "{table}" (id integer PRIMARY KEY)'
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
with self.assertRaises(RuntimeError):
|
|
199
|
+
with self.connection.transaction():
|
|
200
|
+
self.connection.exec(f'INSERT INTO "{table}" VALUES (1)')
|
|
201
|
+
raise RuntimeError('rollback')
|
|
202
|
+
|
|
203
|
+
rows = self.connection.exec(
|
|
204
|
+
f'SELECT id FROM "{table}" ORDER BY id'
|
|
205
|
+
)
|
|
206
|
+
self.assertEqual(rows, [])
|
|
207
|
+
|
|
208
|
+
def test_nested_transaction_uses_savepoint(self) -> None:
|
|
209
|
+
table = f'nested_{uuid.uuid4().hex}'
|
|
210
|
+
self.connection.exec(
|
|
211
|
+
f'CREATE TABLE "{table}" (id integer PRIMARY KEY)'
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
with self.connection.transaction():
|
|
215
|
+
self.connection.exec(f'INSERT INTO "{table}" VALUES (1)')
|
|
216
|
+
with self.assertRaises(RuntimeError):
|
|
217
|
+
with self.connection.transaction():
|
|
218
|
+
self.connection.exec(
|
|
219
|
+
f'INSERT INTO "{table}" VALUES (2)'
|
|
220
|
+
)
|
|
221
|
+
raise RuntimeError('rollback inner')
|
|
222
|
+
self.connection.exec(f'INSERT INTO "{table}" VALUES (3)')
|
|
223
|
+
|
|
224
|
+
rows = self.connection.exec(
|
|
225
|
+
f'SELECT id FROM "{table}" ORDER BY id'
|
|
226
|
+
)
|
|
227
|
+
self.assertEqual(rows, [(1,), (3,)])
|
|
228
|
+
|
|
229
|
+
def test_complete_public_api(self) -> None:
|
|
230
|
+
self.assertTrue(issubclass(PGSQLCommandError, Exception))
|
|
231
|
+
self.assertTrue(issubclass(PGSQLConnectError, Exception))
|
|
232
|
+
self.assertTrue(issubclass(PGSQLCommandResult, list))
|
|
233
|
+
self.assertTrue(issubclass(PGSQLConnection, object))
|
|
234
|
+
self.assertEqual(PGCodes.SUCCESSFUL_COMPLETION.value, '00000')
|
|
235
|
+
|
|
236
|
+
def test_connect_log_does_not_contain_password(self) -> None:
|
|
237
|
+
connection = PGSQLConnection()
|
|
238
|
+
with patch.object(connection._logger, 'info') as info:
|
|
239
|
+
connection.connect(
|
|
240
|
+
self.HOST,
|
|
241
|
+
self.USER,
|
|
242
|
+
self.PASSWORD,
|
|
243
|
+
self.DATABASE,
|
|
244
|
+
self.PORT,
|
|
245
|
+
)
|
|
246
|
+
try:
|
|
247
|
+
calls = '\n'.join(str(call) for call in info.call_args_list)
|
|
248
|
+
self.assertNotIn(self.PASSWORD, calls)
|
|
249
|
+
finally:
|
|
250
|
+
connection.disconnect()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
if __name__ == '__main__':
|
|
254
|
+
unittest.main(verbosity=2)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PostgreSQL plugin for xbot.framework.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from xbot.plugins.pgsql.codes import PGCodes
|
|
6
|
+
from xbot.plugins.pgsql.errors import PGSQLCommandError, PGSQLConnectError
|
|
7
|
+
from xbot.plugins.pgsql.pgsql import PGSQLCommandResult, PGSQLConnection
|
|
8
|
+
from xbot.plugins.pgsql.version import __version__
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
'PGCodes',
|
|
12
|
+
'PGSQLCommandError',
|
|
13
|
+
'PGSQLCommandResult',
|
|
14
|
+
'PGSQLConnectError',
|
|
15
|
+
'PGSQLConnection',
|
|
16
|
+
'__version__',
|
|
17
|
+
]
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PostgreSQL 14-18 SQLSTATE codes.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from enum import Enum, unique
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@unique
|
|
9
|
+
class PGCodes(str, Enum):
|
|
10
|
+
"""
|
|
11
|
+
PostgreSQL 14-18 official SQLSTATE codes.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
# Class 00 - Successful Completion
|
|
15
|
+
SUCCESSFUL_COMPLETION = '00000'
|
|
16
|
+
|
|
17
|
+
# Class 01 - Warning
|
|
18
|
+
WARNING = '01000'
|
|
19
|
+
NULL_VALUE_ELIMINATED_IN_SET_FUNCTION = '01003'
|
|
20
|
+
STRING_DATA_RIGHT_TRUNCATION_01 = '01004'
|
|
21
|
+
PRIVILEGE_NOT_REVOKED = '01006'
|
|
22
|
+
PRIVILEGE_NOT_GRANTED = '01007'
|
|
23
|
+
IMPLICIT_ZERO_BIT_PADDING = '01008'
|
|
24
|
+
DYNAMIC_RESULT_SETS_RETURNED = '0100C'
|
|
25
|
+
DEPRECATED_FEATURE = '01P01'
|
|
26
|
+
|
|
27
|
+
# Class 02 - No Data (this is also a warning class per the SQL standard)
|
|
28
|
+
NO_DATA = '02000'
|
|
29
|
+
NO_ADDITIONAL_DYNAMIC_RESULT_SETS_RETURNED = '02001'
|
|
30
|
+
|
|
31
|
+
# Class 03 - SQL Statement Not Yet Complete
|
|
32
|
+
SQL_STATEMENT_NOT_YET_COMPLETE = '03000'
|
|
33
|
+
|
|
34
|
+
# Class 08 - Connection Exception
|
|
35
|
+
CONNECTION_EXCEPTION = '08000'
|
|
36
|
+
SQLCLIENT_UNABLE_TO_ESTABLISH_SQLCONNECTION = '08001'
|
|
37
|
+
CONNECTION_DOES_NOT_EXIST = '08003'
|
|
38
|
+
SQLSERVER_REJECTED_ESTABLISHMENT_OF_SQLCONNECTION = '08004'
|
|
39
|
+
CONNECTION_FAILURE = '08006'
|
|
40
|
+
TRANSACTION_RESOLUTION_UNKNOWN = '08007'
|
|
41
|
+
PROTOCOL_VIOLATION = '08P01'
|
|
42
|
+
|
|
43
|
+
# Class 09 - Triggered Action Exception
|
|
44
|
+
TRIGGERED_ACTION_EXCEPTION = '09000'
|
|
45
|
+
|
|
46
|
+
# Class 0A - Feature Not Supported
|
|
47
|
+
FEATURE_NOT_SUPPORTED = '0A000'
|
|
48
|
+
|
|
49
|
+
# Class 0B - Invalid Transaction Initiation
|
|
50
|
+
INVALID_TRANSACTION_INITIATION = '0B000'
|
|
51
|
+
|
|
52
|
+
# Class 0F - Locator Exception
|
|
53
|
+
LOCATOR_EXCEPTION = '0F000'
|
|
54
|
+
INVALID_LOCATOR_SPECIFICATION = '0F001'
|
|
55
|
+
|
|
56
|
+
# Class 0L - Invalid Grantor
|
|
57
|
+
INVALID_GRANTOR = '0L000'
|
|
58
|
+
INVALID_GRANT_OPERATION = '0LP01'
|
|
59
|
+
|
|
60
|
+
# Class 0P - Invalid Role Specification
|
|
61
|
+
INVALID_ROLE_SPECIFICATION = '0P000'
|
|
62
|
+
|
|
63
|
+
# Class 0Z - Diagnostics Exception
|
|
64
|
+
DIAGNOSTICS_EXCEPTION = '0Z000'
|
|
65
|
+
STACKED_DIAGNOSTICS_ACCESSED_WITHOUT_ACTIVE_HANDLER = '0Z002'
|
|
66
|
+
|
|
67
|
+
# Class 10 - XQuery Error
|
|
68
|
+
INVALID_ARGUMENT_FOR_XQUERY = '10608'
|
|
69
|
+
|
|
70
|
+
# Class 20 - Case Not Found
|
|
71
|
+
CASE_NOT_FOUND = '20000'
|
|
72
|
+
|
|
73
|
+
# Class 21 - Cardinality Violation
|
|
74
|
+
CARDINALITY_VIOLATION = '21000'
|
|
75
|
+
|
|
76
|
+
# Class 22 - Data Exception
|
|
77
|
+
DATA_EXCEPTION = '22000'
|
|
78
|
+
STRING_DATA_RIGHT_TRUNCATION_22 = '22001'
|
|
79
|
+
NULL_VALUE_NO_INDICATOR_PARAMETER = '22002'
|
|
80
|
+
NUMERIC_VALUE_OUT_OF_RANGE = '22003'
|
|
81
|
+
NULL_VALUE_NOT_ALLOWED_22 = '22004'
|
|
82
|
+
ERROR_IN_ASSIGNMENT = '22005'
|
|
83
|
+
INVALID_DATETIME_FORMAT = '22007'
|
|
84
|
+
DATETIME_FIELD_OVERFLOW = '22008'
|
|
85
|
+
INVALID_TIME_ZONE_DISPLACEMENT_VALUE = '22009'
|
|
86
|
+
ESCAPE_CHARACTER_CONFLICT = '2200B'
|
|
87
|
+
INVALID_USE_OF_ESCAPE_CHARACTER = '2200C'
|
|
88
|
+
INVALID_ESCAPE_OCTET = '2200D'
|
|
89
|
+
ZERO_LENGTH_CHARACTER_STRING = '2200F'
|
|
90
|
+
MOST_SPECIFIC_TYPE_MISMATCH = '2200G'
|
|
91
|
+
SEQUENCE_GENERATOR_LIMIT_EXCEEDED = '2200H'
|
|
92
|
+
NOT_AN_XML_DOCUMENT = '2200L'
|
|
93
|
+
INVALID_XML_DOCUMENT = '2200M'
|
|
94
|
+
INVALID_XML_CONTENT = '2200N'
|
|
95
|
+
INVALID_XML_COMMENT = '2200S'
|
|
96
|
+
INVALID_XML_PROCESSING_INSTRUCTION = '2200T'
|
|
97
|
+
INVALID_INDICATOR_PARAMETER_VALUE = '22010'
|
|
98
|
+
SUBSTRING_ERROR = '22011'
|
|
99
|
+
DIVISION_BY_ZERO = '22012'
|
|
100
|
+
INVALID_PRECEDING_OR_FOLLOWING_SIZE = '22013'
|
|
101
|
+
INVALID_ARGUMENT_FOR_NTILE_FUNCTION = '22014'
|
|
102
|
+
INTERVAL_FIELD_OVERFLOW = '22015'
|
|
103
|
+
INVALID_ARGUMENT_FOR_NTH_VALUE_FUNCTION = '22016'
|
|
104
|
+
INVALID_CHARACTER_VALUE_FOR_CAST = '22018'
|
|
105
|
+
INVALID_ESCAPE_CHARACTER = '22019'
|
|
106
|
+
INVALID_REGULAR_EXPRESSION = '2201B'
|
|
107
|
+
INVALID_ARGUMENT_FOR_LOGARITHM = '2201E'
|
|
108
|
+
INVALID_ARGUMENT_FOR_POWER_FUNCTION = '2201F'
|
|
109
|
+
INVALID_ARGUMENT_FOR_WIDTH_BUCKET_FUNCTION = '2201G'
|
|
110
|
+
INVALID_ROW_COUNT_IN_LIMIT_CLAUSE = '2201W'
|
|
111
|
+
INVALID_ROW_COUNT_IN_RESULT_OFFSET_CLAUSE = '2201X'
|
|
112
|
+
CHARACTER_NOT_IN_REPERTOIRE = '22021'
|
|
113
|
+
INDICATOR_OVERFLOW = '22022'
|
|
114
|
+
INVALID_PARAMETER_VALUE = '22023'
|
|
115
|
+
UNTERMINATED_C_STRING = '22024'
|
|
116
|
+
INVALID_ESCAPE_SEQUENCE = '22025'
|
|
117
|
+
STRING_DATA_LENGTH_MISMATCH = '22026'
|
|
118
|
+
TRIM_ERROR = '22027'
|
|
119
|
+
ARRAY_SUBSCRIPT_ERROR = '2202E'
|
|
120
|
+
INVALID_TABLESAMPLE_REPEAT = '2202G'
|
|
121
|
+
INVALID_TABLESAMPLE_ARGUMENT = '2202H'
|
|
122
|
+
DUPLICATE_JSON_OBJECT_KEY_VALUE = '22030'
|
|
123
|
+
INVALID_ARGUMENT_FOR_SQL_JSON_DATETIME_FUNCTION = '22031'
|
|
124
|
+
INVALID_JSON_TEXT = '22032'
|
|
125
|
+
INVALID_SQL_JSON_SUBSCRIPT = '22033'
|
|
126
|
+
MORE_THAN_ONE_SQL_JSON_ITEM = '22034'
|
|
127
|
+
NO_SQL_JSON_ITEM = '22035'
|
|
128
|
+
NON_NUMERIC_SQL_JSON_ITEM = '22036'
|
|
129
|
+
NON_UNIQUE_KEYS_IN_A_JSON_OBJECT = '22037'
|
|
130
|
+
SINGLETON_SQL_JSON_ITEM_REQUIRED = '22038'
|
|
131
|
+
SQL_JSON_ARRAY_NOT_FOUND = '22039'
|
|
132
|
+
SQL_JSON_MEMBER_NOT_FOUND = '2203A'
|
|
133
|
+
SQL_JSON_NUMBER_NOT_FOUND = '2203B'
|
|
134
|
+
SQL_JSON_OBJECT_NOT_FOUND = '2203C'
|
|
135
|
+
TOO_MANY_JSON_ARRAY_ELEMENTS = '2203D'
|
|
136
|
+
TOO_MANY_JSON_OBJECT_MEMBERS = '2203E'
|
|
137
|
+
SQL_JSON_SCALAR_REQUIRED = '2203F'
|
|
138
|
+
SQL_JSON_ITEM_CANNOT_BE_CAST_TO_TARGET_TYPE = '2203G'
|
|
139
|
+
FLOATING_POINT_EXCEPTION = '22P01'
|
|
140
|
+
INVALID_TEXT_REPRESENTATION = '22P02'
|
|
141
|
+
INVALID_BINARY_REPRESENTATION = '22P03'
|
|
142
|
+
BAD_COPY_FILE_FORMAT = '22P04'
|
|
143
|
+
UNTRANSLATABLE_CHARACTER = '22P05'
|
|
144
|
+
NONSTANDARD_USE_OF_ESCAPE_CHARACTER = '22P06'
|
|
145
|
+
|
|
146
|
+
# Class 23 - Integrity Constraint Violation
|
|
147
|
+
INTEGRITY_CONSTRAINT_VIOLATION = '23000'
|
|
148
|
+
RESTRICT_VIOLATION = '23001'
|
|
149
|
+
NOT_NULL_VIOLATION = '23502'
|
|
150
|
+
FOREIGN_KEY_VIOLATION = '23503'
|
|
151
|
+
UNIQUE_VIOLATION = '23505'
|
|
152
|
+
CHECK_VIOLATION = '23514'
|
|
153
|
+
EXCLUSION_VIOLATION = '23P01'
|
|
154
|
+
|
|
155
|
+
# Class 24 - Invalid Cursor State
|
|
156
|
+
INVALID_CURSOR_STATE = '24000'
|
|
157
|
+
|
|
158
|
+
# Class 25 - Invalid Transaction State
|
|
159
|
+
INVALID_TRANSACTION_STATE = '25000'
|
|
160
|
+
ACTIVE_SQL_TRANSACTION = '25001'
|
|
161
|
+
BRANCH_TRANSACTION_ALREADY_ACTIVE = '25002'
|
|
162
|
+
INAPPROPRIATE_ACCESS_MODE_FOR_BRANCH_TRANSACTION = '25003'
|
|
163
|
+
INAPPROPRIATE_ISOLATION_LEVEL_FOR_BRANCH_TRANSACTION = '25004'
|
|
164
|
+
NO_ACTIVE_SQL_TRANSACTION_FOR_BRANCH_TRANSACTION = '25005'
|
|
165
|
+
READ_ONLY_SQL_TRANSACTION = '25006'
|
|
166
|
+
SCHEMA_AND_DATA_STATEMENT_MIXING_NOT_SUPPORTED = '25007'
|
|
167
|
+
HELD_CURSOR_REQUIRES_SAME_ISOLATION_LEVEL = '25008'
|
|
168
|
+
NO_ACTIVE_SQL_TRANSACTION = '25P01'
|
|
169
|
+
IN_FAILED_SQL_TRANSACTION = '25P02'
|
|
170
|
+
IDLE_IN_TRANSACTION_SESSION_TIMEOUT = '25P03'
|
|
171
|
+
TRANSACTION_TIMEOUT = '25P04'
|
|
172
|
+
|
|
173
|
+
# Class 26 - Invalid SQL Statement Name
|
|
174
|
+
INVALID_SQL_STATEMENT_NAME = '26000'
|
|
175
|
+
|
|
176
|
+
# Class 27 - Triggered Data Change Violation
|
|
177
|
+
TRIGGERED_DATA_CHANGE_VIOLATION = '27000'
|
|
178
|
+
|
|
179
|
+
# Class 28 - Invalid Authorization Specification
|
|
180
|
+
INVALID_AUTHORIZATION_SPECIFICATION = '28000'
|
|
181
|
+
INVALID_PASSWORD = '28P01'
|
|
182
|
+
|
|
183
|
+
# Class 2B - Dependent Privilege Descriptors Still Exist
|
|
184
|
+
DEPENDENT_PRIVILEGE_DESCRIPTORS_STILL_EXIST = '2B000'
|
|
185
|
+
DEPENDENT_OBJECTS_STILL_EXIST = '2BP01'
|
|
186
|
+
|
|
187
|
+
# Class 2D - Invalid Transaction Termination
|
|
188
|
+
INVALID_TRANSACTION_TERMINATION = '2D000'
|
|
189
|
+
|
|
190
|
+
# Class 2F - SQL Routine Exception
|
|
191
|
+
SQL_ROUTINE_EXCEPTION = '2F000'
|
|
192
|
+
MODIFYING_SQL_DATA_NOT_PERMITTED_2F = '2F002'
|
|
193
|
+
PROHIBITED_SQL_STATEMENT_ATTEMPTED_2F = '2F003'
|
|
194
|
+
READING_SQL_DATA_NOT_PERMITTED_2F = '2F004'
|
|
195
|
+
FUNCTION_EXECUTED_NO_RETURN_STATEMENT = '2F005'
|
|
196
|
+
|
|
197
|
+
# Class 34 - Invalid Cursor Name
|
|
198
|
+
INVALID_CURSOR_NAME = '34000'
|
|
199
|
+
|
|
200
|
+
# Class 38 - External Routine Exception
|
|
201
|
+
EXTERNAL_ROUTINE_EXCEPTION = '38000'
|
|
202
|
+
CONTAINING_SQL_NOT_PERMITTED = '38001'
|
|
203
|
+
MODIFYING_SQL_DATA_NOT_PERMITTED_38 = '38002'
|
|
204
|
+
PROHIBITED_SQL_STATEMENT_ATTEMPTED_38 = '38003'
|
|
205
|
+
READING_SQL_DATA_NOT_PERMITTED_38 = '38004'
|
|
206
|
+
|
|
207
|
+
# Class 39 - External Routine Invocation Exception
|
|
208
|
+
EXTERNAL_ROUTINE_INVOCATION_EXCEPTION = '39000'
|
|
209
|
+
INVALID_SQLSTATE_RETURNED = '39001'
|
|
210
|
+
NULL_VALUE_NOT_ALLOWED_39 = '39004'
|
|
211
|
+
TRIGGER_PROTOCOL_VIOLATED = '39P01'
|
|
212
|
+
SRF_PROTOCOL_VIOLATED = '39P02'
|
|
213
|
+
EVENT_TRIGGER_PROTOCOL_VIOLATED = '39P03'
|
|
214
|
+
|
|
215
|
+
# Class 3B - Savepoint Exception
|
|
216
|
+
SAVEPOINT_EXCEPTION = '3B000'
|
|
217
|
+
INVALID_SAVEPOINT_SPECIFICATION = '3B001'
|
|
218
|
+
|
|
219
|
+
# Class 3D - Invalid Catalog Name
|
|
220
|
+
INVALID_CATALOG_NAME = '3D000'
|
|
221
|
+
|
|
222
|
+
# Class 3F - Invalid Schema Name
|
|
223
|
+
INVALID_SCHEMA_NAME = '3F000'
|
|
224
|
+
|
|
225
|
+
# Class 40 - Transaction Rollback
|
|
226
|
+
TRANSACTION_ROLLBACK = '40000'
|
|
227
|
+
SERIALIZATION_FAILURE = '40001'
|
|
228
|
+
TRANSACTION_INTEGRITY_CONSTRAINT_VIOLATION = '40002'
|
|
229
|
+
STATEMENT_COMPLETION_UNKNOWN = '40003'
|
|
230
|
+
DEADLOCK_DETECTED = '40P01'
|
|
231
|
+
|
|
232
|
+
# Class 42 - Syntax Error or Access Rule Violation
|
|
233
|
+
SYNTAX_ERROR_OR_ACCESS_RULE_VIOLATION = '42000'
|
|
234
|
+
INSUFFICIENT_PRIVILEGE = '42501'
|
|
235
|
+
SYNTAX_ERROR = '42601'
|
|
236
|
+
INVALID_NAME = '42602'
|
|
237
|
+
INVALID_COLUMN_DEFINITION = '42611'
|
|
238
|
+
NAME_TOO_LONG = '42622'
|
|
239
|
+
DUPLICATE_COLUMN = '42701'
|
|
240
|
+
AMBIGUOUS_COLUMN = '42702'
|
|
241
|
+
UNDEFINED_COLUMN = '42703'
|
|
242
|
+
UNDEFINED_OBJECT = '42704'
|
|
243
|
+
DUPLICATE_OBJECT = '42710'
|
|
244
|
+
DUPLICATE_ALIAS = '42712'
|
|
245
|
+
DUPLICATE_FUNCTION = '42723'
|
|
246
|
+
AMBIGUOUS_FUNCTION = '42725'
|
|
247
|
+
GROUPING_ERROR = '42803'
|
|
248
|
+
DATATYPE_MISMATCH = '42804'
|
|
249
|
+
WRONG_OBJECT_TYPE = '42809'
|
|
250
|
+
INVALID_FOREIGN_KEY = '42830'
|
|
251
|
+
CANNOT_COERCE = '42846'
|
|
252
|
+
UNDEFINED_FUNCTION = '42883'
|
|
253
|
+
GENERATED_ALWAYS = '428C9'
|
|
254
|
+
RESERVED_NAME = '42939'
|
|
255
|
+
UNDEFINED_TABLE = '42P01'
|
|
256
|
+
UNDEFINED_PARAMETER = '42P02'
|
|
257
|
+
DUPLICATE_CURSOR = '42P03'
|
|
258
|
+
DUPLICATE_DATABASE = '42P04'
|
|
259
|
+
DUPLICATE_PREPARED_STATEMENT = '42P05'
|
|
260
|
+
DUPLICATE_SCHEMA = '42P06'
|
|
261
|
+
DUPLICATE_TABLE = '42P07'
|
|
262
|
+
AMBIGUOUS_PARAMETER = '42P08'
|
|
263
|
+
AMBIGUOUS_ALIAS = '42P09'
|
|
264
|
+
INVALID_COLUMN_REFERENCE = '42P10'
|
|
265
|
+
INVALID_CURSOR_DEFINITION = '42P11'
|
|
266
|
+
INVALID_DATABASE_DEFINITION = '42P12'
|
|
267
|
+
INVALID_FUNCTION_DEFINITION = '42P13'
|
|
268
|
+
INVALID_PREPARED_STATEMENT_DEFINITION = '42P14'
|
|
269
|
+
INVALID_SCHEMA_DEFINITION = '42P15'
|
|
270
|
+
INVALID_TABLE_DEFINITION = '42P16'
|
|
271
|
+
INVALID_OBJECT_DEFINITION = '42P17'
|
|
272
|
+
INDETERMINATE_DATATYPE = '42P18'
|
|
273
|
+
INVALID_RECURSION = '42P19'
|
|
274
|
+
WINDOWING_ERROR = '42P20'
|
|
275
|
+
COLLATION_MISMATCH = '42P21'
|
|
276
|
+
INDETERMINATE_COLLATION = '42P22'
|
|
277
|
+
|
|
278
|
+
# Class 44 - WITH CHECK OPTION Violation
|
|
279
|
+
WITH_CHECK_OPTION_VIOLATION = '44000'
|
|
280
|
+
|
|
281
|
+
# Class 53 - Insufficient Resources
|
|
282
|
+
INSUFFICIENT_RESOURCES = '53000'
|
|
283
|
+
DISK_FULL = '53100'
|
|
284
|
+
OUT_OF_MEMORY = '53200'
|
|
285
|
+
TOO_MANY_CONNECTIONS = '53300'
|
|
286
|
+
CONFIGURATION_LIMIT_EXCEEDED = '53400'
|
|
287
|
+
|
|
288
|
+
# Class 54 - Program Limit Exceeded
|
|
289
|
+
PROGRAM_LIMIT_EXCEEDED = '54000'
|
|
290
|
+
STATEMENT_TOO_COMPLEX = '54001'
|
|
291
|
+
TOO_MANY_COLUMNS = '54011'
|
|
292
|
+
TOO_MANY_ARGUMENTS = '54023'
|
|
293
|
+
|
|
294
|
+
# Class 55 - Object Not In Prerequisite State
|
|
295
|
+
OBJECT_NOT_IN_PREREQUISITE_STATE = '55000'
|
|
296
|
+
OBJECT_IN_USE = '55006'
|
|
297
|
+
CANT_CHANGE_RUNTIME_PARAM = '55P02'
|
|
298
|
+
LOCK_NOT_AVAILABLE = '55P03'
|
|
299
|
+
UNSAFE_NEW_ENUM_VALUE_USAGE = '55P04'
|
|
300
|
+
|
|
301
|
+
# Class 57 - Operator Intervention
|
|
302
|
+
OPERATOR_INTERVENTION = '57000'
|
|
303
|
+
QUERY_CANCELED = '57014'
|
|
304
|
+
ADMIN_SHUTDOWN = '57P01'
|
|
305
|
+
CRASH_SHUTDOWN = '57P02'
|
|
306
|
+
CANNOT_CONNECT_NOW = '57P03'
|
|
307
|
+
DATABASE_DROPPED = '57P04'
|
|
308
|
+
IDLE_SESSION_TIMEOUT = '57P05'
|
|
309
|
+
|
|
310
|
+
# Class 58 - System Error (errors external to PostgreSQL itself)
|
|
311
|
+
SYSTEM_ERROR = '58000'
|
|
312
|
+
IO_ERROR = '58030'
|
|
313
|
+
UNDEFINED_FILE = '58P01'
|
|
314
|
+
DUPLICATE_FILE = '58P02'
|
|
315
|
+
FILE_NAME_TOO_LONG = '58P03'
|
|
316
|
+
|
|
317
|
+
# Class 72 - Snapshot Failure
|
|
318
|
+
SNAPSHOT_TOO_OLD = '72000'
|
|
319
|
+
|
|
320
|
+
# Class F0 - Configuration File Error
|
|
321
|
+
CONFIG_FILE_ERROR = 'F0000'
|
|
322
|
+
LOCK_FILE_EXISTS = 'F0001'
|
|
323
|
+
|
|
324
|
+
# Class HV - Foreign Data Wrapper Error (SQL/MED)
|
|
325
|
+
FDW_ERROR = 'HV000'
|
|
326
|
+
FDW_OUT_OF_MEMORY = 'HV001'
|
|
327
|
+
FDW_DYNAMIC_PARAMETER_VALUE_NEEDED = 'HV002'
|
|
328
|
+
FDW_INVALID_DATA_TYPE = 'HV004'
|
|
329
|
+
FDW_COLUMN_NAME_NOT_FOUND = 'HV005'
|
|
330
|
+
FDW_INVALID_DATA_TYPE_DESCRIPTORS = 'HV006'
|
|
331
|
+
FDW_INVALID_COLUMN_NAME = 'HV007'
|
|
332
|
+
FDW_INVALID_COLUMN_NUMBER = 'HV008'
|
|
333
|
+
FDW_INVALID_USE_OF_NULL_POINTER = 'HV009'
|
|
334
|
+
FDW_INVALID_STRING_FORMAT = 'HV00A'
|
|
335
|
+
FDW_INVALID_HANDLE = 'HV00B'
|
|
336
|
+
FDW_INVALID_OPTION_INDEX = 'HV00C'
|
|
337
|
+
FDW_INVALID_OPTION_NAME = 'HV00D'
|
|
338
|
+
FDW_OPTION_NAME_NOT_FOUND = 'HV00J'
|
|
339
|
+
FDW_REPLY_HANDLE = 'HV00K'
|
|
340
|
+
FDW_UNABLE_TO_CREATE_EXECUTION = 'HV00L'
|
|
341
|
+
FDW_UNABLE_TO_CREATE_REPLY = 'HV00M'
|
|
342
|
+
FDW_UNABLE_TO_ESTABLISH_CONNECTION = 'HV00N'
|
|
343
|
+
FDW_NO_SCHEMAS = 'HV00P'
|
|
344
|
+
FDW_SCHEMA_NOT_FOUND = 'HV00Q'
|
|
345
|
+
FDW_TABLE_NOT_FOUND = 'HV00R'
|
|
346
|
+
FDW_FUNCTION_SEQUENCE_ERROR = 'HV010'
|
|
347
|
+
FDW_TOO_MANY_HANDLES = 'HV014'
|
|
348
|
+
FDW_INCONSISTENT_DESCRIPTOR_INFORMATION = 'HV021'
|
|
349
|
+
FDW_INVALID_ATTRIBUTE_VALUE = 'HV024'
|
|
350
|
+
FDW_INVALID_STRING_LENGTH_OR_BUFFER_LENGTH = 'HV090'
|
|
351
|
+
FDW_INVALID_DESCRIPTOR_FIELD_IDENTIFIER = 'HV091'
|
|
352
|
+
|
|
353
|
+
# Class P0 - PL/pgSQL Error
|
|
354
|
+
PLPGSQL_ERROR = 'P0000'
|
|
355
|
+
RAISE_EXCEPTION = 'P0001'
|
|
356
|
+
NO_DATA_FOUND = 'P0002'
|
|
357
|
+
TOO_MANY_ROWS = 'P0003'
|
|
358
|
+
ASSERT_FAILURE = 'P0004'
|
|
359
|
+
|
|
360
|
+
# Class XX - Internal Error
|
|
361
|
+
INTERNAL_ERROR = 'XX000'
|
|
362
|
+
DATA_CORRUPTED = 'XX001'
|
|
363
|
+
INDEX_CORRUPTED = 'XX002'
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public exceptions.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from xbot.plugins.pgsql.codes import PGCodes
|
|
9
|
+
from xbot.plugins.pgsql.pgsql import PGSQLCommandResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PGSQLConnectError(Exception):
|
|
13
|
+
"""
|
|
14
|
+
Raised when a PostgreSQL connection cannot be established.
|
|
15
|
+
|
|
16
|
+
The original Psycopg exception is preserved via ``__cause__``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PGSQLCommandError(Exception):
|
|
21
|
+
"""
|
|
22
|
+
Raised when a SQL command result differs from the expectation.
|
|
23
|
+
|
|
24
|
+
Exposes ``.result`` (``PGSQLCommandResult``) and ``.expect``
|
|
25
|
+
(``PGCodes | None``). When the error originates from a caught
|
|
26
|
+
``psycopg.Error``, the original exception is preserved via
|
|
27
|
+
``__cause__``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
message: str,
|
|
33
|
+
*,
|
|
34
|
+
result: 'PGSQLCommandResult',
|
|
35
|
+
expect: 'PGCodes | None',
|
|
36
|
+
) -> None:
|
|
37
|
+
"""
|
|
38
|
+
:param message: error message.
|
|
39
|
+
:param result: command result.
|
|
40
|
+
:param expect: expected SQLSTATE code.
|
|
41
|
+
"""
|
|
42
|
+
super().__init__(message)
|
|
43
|
+
self.result = result
|
|
44
|
+
self.expect = expect
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Synchronous PostgreSQL connection and command result.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import textwrap
|
|
6
|
+
|
|
7
|
+
from collections.abc import Iterable, Generator
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import psycopg
|
|
12
|
+
from psycopg import Connection
|
|
13
|
+
from psycopg.rows import tuple_row
|
|
14
|
+
from xbot.framework.logger import ExtraAdapter, getlogger
|
|
15
|
+
|
|
16
|
+
from cli_helpers import tabular_output
|
|
17
|
+
|
|
18
|
+
from xbot.plugins.pgsql.codes import PGCodes
|
|
19
|
+
from xbot.plugins.pgsql.errors import PGSQLCommandError, PGSQLConnectError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
logger = getlogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PGSQLCommandResult(list[tuple[Any, ...]]):
|
|
26
|
+
"""
|
|
27
|
+
Rows and metadata produced by one SQL command.
|
|
28
|
+
|
|
29
|
+
A ``list[tuple[Any, ...]]`` subclass. Each element is a row tuple;
|
|
30
|
+
the object also carries ``header`` (column names), ``rc`` (SQLSTATE,
|
|
31
|
+
``"00000"`` on success) and ``cmd`` (the SQL command for logging).
|
|
32
|
+
Calling ``str(result)`` renders rows as a psql-style table that
|
|
33
|
+
updates live as rows are appended.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
rows: Iterable[tuple[Any, ...]] = (),
|
|
39
|
+
*,
|
|
40
|
+
header: tuple[str, ...] = (),
|
|
41
|
+
rc: str | None = '00000',
|
|
42
|
+
cmd: str = ''
|
|
43
|
+
) -> None:
|
|
44
|
+
"""
|
|
45
|
+
:param rows: row tuples.
|
|
46
|
+
:param header: column names.
|
|
47
|
+
:param rc: SQLSTATE code.
|
|
48
|
+
:param cmd: SQL command.
|
|
49
|
+
"""
|
|
50
|
+
super().__init__(rows)
|
|
51
|
+
self.header = header
|
|
52
|
+
self.rc = rc
|
|
53
|
+
self.cmd = cmd
|
|
54
|
+
|
|
55
|
+
def __str__(self) -> str:
|
|
56
|
+
"""
|
|
57
|
+
Render the current rows as a psql-style table.
|
|
58
|
+
"""
|
|
59
|
+
lines = tabular_output.format_output(
|
|
60
|
+
self,
|
|
61
|
+
self.header,
|
|
62
|
+
format_name='psql',
|
|
63
|
+
)
|
|
64
|
+
return '\n'.join(lines)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class PGSQLConnection(object):
|
|
68
|
+
"""
|
|
69
|
+
One synchronous PostgreSQL connection.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self) -> None:
|
|
73
|
+
self._conn: Connection[tuple[Any, ...]] | None = None
|
|
74
|
+
self._logger = ExtraAdapter(logger, {})
|
|
75
|
+
|
|
76
|
+
def connect(
|
|
77
|
+
self,
|
|
78
|
+
host: str,
|
|
79
|
+
user: str,
|
|
80
|
+
password: str,
|
|
81
|
+
database: str,
|
|
82
|
+
port: int = 5432,
|
|
83
|
+
timeout: int = 5
|
|
84
|
+
) -> None:
|
|
85
|
+
"""
|
|
86
|
+
Open an autocommit PostgreSQL connection.
|
|
87
|
+
|
|
88
|
+
:param host: hostname or ip.
|
|
89
|
+
:param user: user name.
|
|
90
|
+
:param password: user password.
|
|
91
|
+
:param database: database name.
|
|
92
|
+
:param port: PostgreSQL port.
|
|
93
|
+
:param timeout: connect timeout(s).
|
|
94
|
+
"""
|
|
95
|
+
if self._conn is not None and not self._conn.closed:
|
|
96
|
+
return
|
|
97
|
+
prefix = f'postgres://{user}@{host}:{port}/{database}'
|
|
98
|
+
self._logger.extra['prefix'] = prefix
|
|
99
|
+
self._logger.info('Connecting...')
|
|
100
|
+
try:
|
|
101
|
+
self._conn = psycopg.connect(
|
|
102
|
+
host=host,
|
|
103
|
+
user=user,
|
|
104
|
+
password=password,
|
|
105
|
+
dbname=database,
|
|
106
|
+
port=port,
|
|
107
|
+
connect_timeout=timeout,
|
|
108
|
+
autocommit=True,
|
|
109
|
+
row_factory=tuple_row,
|
|
110
|
+
)
|
|
111
|
+
except psycopg.Error as e:
|
|
112
|
+
raise PGSQLConnectError(f'Could not connect to {prefix}: {str(e)}') from e
|
|
113
|
+
|
|
114
|
+
def disconnect(self) -> None:
|
|
115
|
+
"""
|
|
116
|
+
Close the connection; repeated calls are harmless.
|
|
117
|
+
"""
|
|
118
|
+
if self._conn is not None and not self._conn.closed:
|
|
119
|
+
self._conn.close()
|
|
120
|
+
self._conn = None
|
|
121
|
+
|
|
122
|
+
@contextmanager
|
|
123
|
+
def transaction(self) -> Generator[None, None, None]:
|
|
124
|
+
"""
|
|
125
|
+
Commit on success and roll back on exception.
|
|
126
|
+
Nested calls use savepoints automatically.
|
|
127
|
+
|
|
128
|
+
>>> db = PGSQLConnection() # doctest: +SKIP
|
|
129
|
+
>>> db.connect('127.0.0.1', 'postgres', 'secret', 'postgres')
|
|
130
|
+
>>> with db.transaction():
|
|
131
|
+
... db.exec("INSERT INTO t VALUES (1)") # commit on exit
|
|
132
|
+
>>> with db.transaction():
|
|
133
|
+
... db.exec("INSERT INTO t VALUES (2)")
|
|
134
|
+
... raise RuntimeError('rollback') # row 2 is rolled back
|
|
135
|
+
"""
|
|
136
|
+
with self._conn.transaction():
|
|
137
|
+
yield
|
|
138
|
+
|
|
139
|
+
def exec(
|
|
140
|
+
self,
|
|
141
|
+
cmd: str,
|
|
142
|
+
expect: PGCodes | None = PGCodes.SUCCESSFUL_COMPLETION
|
|
143
|
+
) -> PGSQLCommandResult:
|
|
144
|
+
"""
|
|
145
|
+
Execute one SQL command and check its SQLSTATE.
|
|
146
|
+
|
|
147
|
+
``PGCodes`` is a ``str``-based enum with 263 PostgreSQL 14-18
|
|
148
|
+
SQLSTATE codes. Pass ``expect=None`` to accept any outcome
|
|
149
|
+
without raising.
|
|
150
|
+
|
|
151
|
+
:param cmd: SQL command.
|
|
152
|
+
:param expect: expected SQLSTATE code.
|
|
153
|
+
PGCodes.SUCCESSFUL_COMPLETION: expect SQLSTATE is '00000' (default).
|
|
154
|
+
PGCodes.UNDEFINED_TABLE: expect SQLSTATE is '42P01'.
|
|
155
|
+
None: do not check the result.
|
|
156
|
+
|
|
157
|
+
:return: command result (rows, header, rc, cmd).
|
|
158
|
+
|
|
159
|
+
:raises:
|
|
160
|
+
`.PGSQLCommandError` -- if the result is not as expected.
|
|
161
|
+
|
|
162
|
+
>>> db = PGSQLConnection() # doctest: +SKIP
|
|
163
|
+
>>> db.connect('127.0.0.1', 'postgres', 'secret', 'postgres')
|
|
164
|
+
>>> db.exec('SELECT 1') # successful
|
|
165
|
+
>>> db.exec('SELECT * FROM missing') # PGSQLCommandError
|
|
166
|
+
>>> db.exec('SELECT * FROM missing', expect=PGCodes.UNDEFINED_TABLE) # no error
|
|
167
|
+
>>> db.exec('SELECT * FROM missing', expect=None) # no error
|
|
168
|
+
>>> db.exec('SELECT 1', expect=PGCodes.UNDEFINED_TABLE) # PGSQLCommandError
|
|
169
|
+
"""
|
|
170
|
+
extra: dict[str, Any] = {'hook': {}}
|
|
171
|
+
self._logger.info(f"Command: '{cmd}', Expect: '{expect}'", extra=extra)
|
|
172
|
+
cause: psycopg.Error | None = None
|
|
173
|
+
try:
|
|
174
|
+
with self._conn.cursor() as cursor:
|
|
175
|
+
cursor.execute(cmd, prepare=True)
|
|
176
|
+
if cursor.description is None:
|
|
177
|
+
rows: list[tuple[Any, ...]] = []
|
|
178
|
+
header: tuple[str, ...] = ()
|
|
179
|
+
else:
|
|
180
|
+
rows = cursor.fetchall()
|
|
181
|
+
header = tuple(
|
|
182
|
+
column.name for column in cursor.description
|
|
183
|
+
)
|
|
184
|
+
result = PGSQLCommandResult(
|
|
185
|
+
rows,
|
|
186
|
+
header=header,
|
|
187
|
+
rc=PGCodes.SUCCESSFUL_COMPLETION.value,
|
|
188
|
+
cmd=cmd,
|
|
189
|
+
)
|
|
190
|
+
except psycopg.Error as e:
|
|
191
|
+
cause = e
|
|
192
|
+
result = PGSQLCommandResult(
|
|
193
|
+
[],
|
|
194
|
+
header=(),
|
|
195
|
+
rc=getattr(e, 'sqlstate', None),
|
|
196
|
+
cmd=cmd,
|
|
197
|
+
)
|
|
198
|
+
extra['hook']['more'] = result
|
|
199
|
+
if expect != None and result.rc != expect.value:
|
|
200
|
+
msg = textwrap.dedent(f"""\
|
|
201
|
+
Expections not met:
|
|
202
|
+
Command: {cmd}
|
|
203
|
+
Excpect: {expect}
|
|
204
|
+
ReturnCode: {result.rc}
|
|
205
|
+
Output:
|
|
206
|
+
{result}
|
|
207
|
+
""")
|
|
208
|
+
raise PGSQLCommandError(
|
|
209
|
+
msg,
|
|
210
|
+
result=result,
|
|
211
|
+
expect=expect,
|
|
212
|
+
) from cause
|
|
213
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xbot.plugins.pgsql
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PostgreSQL library for xbot.framework
|
|
5
|
+
Author-email: zhaowcheng <zhaowcheng@163.com>
|
|
6
|
+
License-Expression: BSD-2-Clause
|
|
7
|
+
Project-URL: Homepage, https://github.com/zhaowcheng/xbot.plugins.pgsql
|
|
8
|
+
Project-URL: Issues, https://github.com/zhaowcheng/xbot.plugins.pgsql/issues
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: xbot.framework>=0.5.1
|
|
19
|
+
Requires-Dist: psycopg[binary]<4,>=3
|
|
20
|
+
Requires-Dist: cli_helpers
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: build; extra == "dev"
|
|
23
|
+
Requires-Dist: mypy; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
<p align="center">
|
|
27
|
+
<br>English | <a href="README.zh.md">中文</a>
|
|
28
|
+
</p>
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
# xbot.plugins.pgsql
|
|
33
|
+
|
|
34
|
+
PostgreSQL library for xbot.framework.
|
|
35
|
+
|
|
36
|
+
## Introduction
|
|
37
|
+
|
|
38
|
+
A synchronous PostgreSQL client library for [xbot.framework](https://pypi.org/project/xbot.framework/), built on Psycopg 3. It provides a thin connection wrapper with SQLSTATE-based expectation checking, psql-style result rendering, and explicit transaction support.
|
|
39
|
+
|
|
40
|
+
Python >= 3.10, PostgreSQL >= 14. For full API documentation, refer to the docstrings in each module.
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install xbot.plugins.pgsql
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Getting Started
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from xbot.plugins.pgsql import PGCodes, PGSQLConnection
|
|
52
|
+
|
|
53
|
+
db = PGSQLConnection()
|
|
54
|
+
db.connect(
|
|
55
|
+
host="127.0.0.1",
|
|
56
|
+
user="postgres",
|
|
57
|
+
password="secret",
|
|
58
|
+
database="postgres",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
result = db.exec(
|
|
62
|
+
"SELECT 1::integer AS id, 'alice'::text AS name",
|
|
63
|
+
)
|
|
64
|
+
print(result)
|
|
65
|
+
print(result.header)
|
|
66
|
+
print(result.rc)
|
|
67
|
+
print(result.cmd)
|
|
68
|
+
|
|
69
|
+
missing = db.exec(
|
|
70
|
+
"SELECT * FROM missing_table",
|
|
71
|
+
expect=PGCodes.UNDEFINED_TABLE,
|
|
72
|
+
)
|
|
73
|
+
assert missing.rc == "42P01"
|
|
74
|
+
|
|
75
|
+
with db.transaction():
|
|
76
|
+
db.exec("INSERT INTO accounts(id, balance) VALUES (1, 100)")
|
|
77
|
+
|
|
78
|
+
db.disconnect()
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
<p align="center">
|
|
82
|
+
<br>中文 | <a href="README.md">English</a>
|
|
83
|
+
</p>
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
# xbot.plugins.pgsql
|
|
88
|
+
|
|
89
|
+
xbot.framework 的 PostgreSQL 客户端库。
|
|
90
|
+
|
|
91
|
+
## 简介
|
|
92
|
+
|
|
93
|
+
基于 Psycopg 3 的同步 PostgreSQL 客户端库,面向 [xbot.framework](https://pypi.org/project/xbot.framework/)。提供薄封装连接,支持基于 SQLSTATE 的预期检查、psql 风格结果渲染和显式事务。
|
|
94
|
+
|
|
95
|
+
Python >= 3.10,PostgreSQL >= 14。完整 API 文档请参阅各模块的 docstring。
|
|
96
|
+
|
|
97
|
+
## 安装
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pip install xbot.plugins.pgsql
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## 入门
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
from xbot.plugins.pgsql import PGCodes, PGSQLConnection
|
|
107
|
+
|
|
108
|
+
db = PGSQLConnection()
|
|
109
|
+
db.connect(
|
|
110
|
+
host="127.0.0.1",
|
|
111
|
+
user="postgres",
|
|
112
|
+
password="secret",
|
|
113
|
+
database="postgres",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
result = db.exec(
|
|
117
|
+
"SELECT 1::integer AS id, 'alice'::text AS name",
|
|
118
|
+
)
|
|
119
|
+
print(result)
|
|
120
|
+
print(result.header)
|
|
121
|
+
print(result.rc)
|
|
122
|
+
print(result.cmd)
|
|
123
|
+
|
|
124
|
+
missing = db.exec(
|
|
125
|
+
"SELECT * FROM missing_table",
|
|
126
|
+
expect=PGCodes.UNDEFINED_TABLE,
|
|
127
|
+
)
|
|
128
|
+
assert missing.rc == "42P01"
|
|
129
|
+
|
|
130
|
+
with db.transaction():
|
|
131
|
+
db.exec("INSERT INTO accounts(id, balance) VALUES (1, 100)")
|
|
132
|
+
|
|
133
|
+
db.disconnect()
|
|
134
|
+
```
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
tests/run.py,sha256=S7-Nt-cvKStKydBaJptLARRAE7Vs2ng6N-ubsGarhXs,1083
|
|
3
|
+
tests/test_pgsql.py,sha256=3k6r5qb0cNQIjsH8ev1CpVVJ3LFdcw7W65g3d-HL6UA,8947
|
|
4
|
+
xbot/plugins/pgsql/__init__.py,sha256=nSbwueWFzdbcmcENLAsnQiSqEbQ1yzvIG_jxJCrC27g,439
|
|
5
|
+
xbot/plugins/pgsql/codes.py,sha256=5OEIs0YNHbZYGm9jIWczMVuQvu3TTQCn_GD04IAypyg,12400
|
|
6
|
+
xbot/plugins/pgsql/errors.py,sha256=9SRtFCc5ee9r2OMS8SFd8ml8H1LcSlvO8O2WqjLh3WU,1103
|
|
7
|
+
xbot/plugins/pgsql/pgsql.py,sha256=D31ULKlzwHLb2vdGenhPNnmItxpXeIok0ZLeNsQjCig,6877
|
|
8
|
+
xbot/plugins/pgsql/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
xbot/plugins/pgsql/version.py,sha256=yUaeFX3keaqxK7DotElYbBSr-vs68q_f61nlL3WZisw,53
|
|
10
|
+
xbot_plugins_pgsql-0.1.0.dist-info/licenses/LICENSE,sha256=QQgf5JlcyKhtVWQcDwDfv7aU_8RZ5qZKxLnpp3NEXos,1325
|
|
11
|
+
xbot_plugins_pgsql-0.1.0.dist-info/METADATA,sha256=yOpUjIHYUG4J9DrwJ24w_C3ioX3bjj9kFWa5clI4KvQ,3235
|
|
12
|
+
xbot_plugins_pgsql-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
13
|
+
xbot_plugins_pgsql-0.1.0.dist-info/top_level.txt,sha256=4uDSWEEQ4RLxZEPAyhoF4SDnil_CTMm2yOv0bRpEBNU,11
|
|
14
|
+
xbot_plugins_pgsql-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
BSD 2-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022-2023, zhaowcheng <zhaowcheng@163.com>
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
16
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
17
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
18
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
19
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
20
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
21
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
22
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
23
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
24
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|