half-orm-dev 0.16.0a9__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.
- half_orm_dev/__init__.py +1 -0
- half_orm_dev/cli/__init__.py +9 -0
- half_orm_dev/cli/commands/__init__.py +56 -0
- half_orm_dev/cli/commands/apply.py +13 -0
- half_orm_dev/cli/commands/clone.py +102 -0
- half_orm_dev/cli/commands/init.py +331 -0
- half_orm_dev/cli/commands/new.py +15 -0
- half_orm_dev/cli/commands/patch.py +317 -0
- half_orm_dev/cli/commands/prepare.py +21 -0
- half_orm_dev/cli/commands/prepare_release.py +119 -0
- half_orm_dev/cli/commands/promote_to.py +127 -0
- half_orm_dev/cli/commands/release.py +344 -0
- half_orm_dev/cli/commands/restore.py +14 -0
- half_orm_dev/cli/commands/sync.py +13 -0
- half_orm_dev/cli/commands/todo.py +73 -0
- half_orm_dev/cli/commands/undo.py +17 -0
- half_orm_dev/cli/commands/update.py +73 -0
- half_orm_dev/cli/commands/upgrade.py +191 -0
- half_orm_dev/cli/main.py +103 -0
- half_orm_dev/cli_extension.py +38 -0
- half_orm_dev/database.py +1389 -0
- half_orm_dev/hgit.py +1025 -0
- half_orm_dev/hop.py +167 -0
- half_orm_dev/manifest.py +43 -0
- half_orm_dev/modules.py +456 -0
- half_orm_dev/patch.py +281 -0
- half_orm_dev/patch_manager.py +1694 -0
- half_orm_dev/patch_validator.py +335 -0
- half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +34 -0
- half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +2 -0
- half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +3 -0
- half_orm_dev/patches/log +2 -0
- half_orm_dev/patches/sql/half_orm_meta.sql +208 -0
- half_orm_dev/release_manager.py +2841 -0
- half_orm_dev/repo.py +1562 -0
- half_orm_dev/templates/.gitignore +15 -0
- half_orm_dev/templates/MANIFEST.in +1 -0
- half_orm_dev/templates/Pipfile +13 -0
- half_orm_dev/templates/README +25 -0
- half_orm_dev/templates/conftest_template +42 -0
- half_orm_dev/templates/init_module_template +10 -0
- half_orm_dev/templates/module_template_1 +12 -0
- half_orm_dev/templates/module_template_2 +6 -0
- half_orm_dev/templates/module_template_3 +3 -0
- half_orm_dev/templates/relation_test +23 -0
- half_orm_dev/templates/setup.py +81 -0
- half_orm_dev/templates/sql_adapter +9 -0
- half_orm_dev/templates/warning +12 -0
- half_orm_dev/utils.py +49 -0
- half_orm_dev/version.txt +1 -0
- half_orm_dev-0.16.0a9.dist-info/METADATA +935 -0
- half_orm_dev-0.16.0a9.dist-info/RECORD +58 -0
- half_orm_dev-0.16.0a9.dist-info/WHEEL +5 -0
- half_orm_dev-0.16.0a9.dist-info/licenses/AUTHORS +3 -0
- half_orm_dev-0.16.0a9.dist-info/licenses/LICENSE +14 -0
- half_orm_dev-0.16.0a9.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- tests/conftest.py +329 -0
half_orm_dev/database.py
ADDED
|
@@ -0,0 +1,1389 @@
|
|
|
1
|
+
"""Provides the Database class
|
|
2
|
+
"""
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from psycopg2 import OperationalError
|
|
11
|
+
from half_orm.model import Model
|
|
12
|
+
from half_orm.model_errors import UnknownRelation
|
|
13
|
+
from half_orm import utils
|
|
14
|
+
from .utils import HOP_PATH
|
|
15
|
+
|
|
16
|
+
class DatabaseError(Exception):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
class DockerNotAvailableError(Exception):
|
|
20
|
+
"""
|
|
21
|
+
Raised when Docker is not installed or not running.
|
|
22
|
+
|
|
23
|
+
This exception is raised when attempting to use Docker for PostgreSQL
|
|
24
|
+
operations but Docker is not available on the system.
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
Docker not installed:
|
|
28
|
+
DockerNotAvailableError("Docker is not installed on this system")
|
|
29
|
+
|
|
30
|
+
Docker daemon not running:
|
|
31
|
+
DockerNotAvailableError("Docker daemon is not running")
|
|
32
|
+
"""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DockerContainerNotFoundError(Exception):
|
|
37
|
+
"""
|
|
38
|
+
Raised when specified Docker container does not exist.
|
|
39
|
+
|
|
40
|
+
This exception is raised when a database configuration references a
|
|
41
|
+
Docker container that does not exist on the system.
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
Container not found:
|
|
45
|
+
DockerContainerNotFoundError(
|
|
46
|
+
"Docker container 'my_postgres' not found. "
|
|
47
|
+
"Run: docker ps -a to list containers"
|
|
48
|
+
)
|
|
49
|
+
"""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DockerContainerNotRunningError(Exception):
|
|
54
|
+
"""
|
|
55
|
+
Raised when Docker container exists but is not running.
|
|
56
|
+
|
|
57
|
+
This exception is raised when a Docker container exists but is in a
|
|
58
|
+
stopped state and cannot execute PostgreSQL commands.
|
|
59
|
+
|
|
60
|
+
Examples:
|
|
61
|
+
Container stopped:
|
|
62
|
+
DockerContainerNotRunningError(
|
|
63
|
+
"Docker container 'my_postgres' exists but is not running. "
|
|
64
|
+
"Run: docker start my_postgres"
|
|
65
|
+
)
|
|
66
|
+
"""
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Database:
|
|
71
|
+
"""Reads and writes the halfORM connection file
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(self, repo, get_release=True):
|
|
75
|
+
self.__repo = repo
|
|
76
|
+
self.__model = None
|
|
77
|
+
self.__last_release = None
|
|
78
|
+
if self.__repo.name:
|
|
79
|
+
try:
|
|
80
|
+
self.__model = Model(self.__repo.name)
|
|
81
|
+
self.__init(self.__repo.name, get_release)
|
|
82
|
+
except OperationalError as err:
|
|
83
|
+
if not self.__repo.new:
|
|
84
|
+
utils.error(err, 1)
|
|
85
|
+
|
|
86
|
+
def __call__(self, name):
|
|
87
|
+
return self.__class__(self.__repo)
|
|
88
|
+
|
|
89
|
+
def __init(self, name, get_release=True):
|
|
90
|
+
self.__name = name
|
|
91
|
+
if get_release and self.__repo.devel:
|
|
92
|
+
self.__last_release = self.last_release
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def last_release(self):
|
|
96
|
+
"Returns the last release"
|
|
97
|
+
self.__last_release = next(
|
|
98
|
+
self.__model.get_relation_class('half_orm_meta.view.hop_last_release')().ho_select())
|
|
99
|
+
return self.__last_release
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def last_release_s(self):
|
|
103
|
+
"Returns the string representation of the last release X.Y.Z"
|
|
104
|
+
return '{major}.{minor}.{patch}'.format(**self.last_release)
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def model(self):
|
|
108
|
+
"The model (halfORM) of the database"
|
|
109
|
+
return self.__model
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def state(self):
|
|
113
|
+
"The state (str) of the database"
|
|
114
|
+
res = ['[Database]']
|
|
115
|
+
res.append(f'- name: {self.__name}')
|
|
116
|
+
res.append(f"- user: {self._get_connection_params()['user']}")
|
|
117
|
+
res.append(f"- host: {self._get_connection_params()['host']}")
|
|
118
|
+
res.append(f"- port: {self._get_connection_params()['port']}")
|
|
119
|
+
prod = utils.Color.blue(
|
|
120
|
+
True) if self._get_connection_params()['production'] else False
|
|
121
|
+
res.append(f'- production: {prod}')
|
|
122
|
+
if self.__repo.devel:
|
|
123
|
+
res.append(f'- last release: {self.last_release_s}')
|
|
124
|
+
return '\n'.join(res)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def production(self):
|
|
128
|
+
"Returns whether the database is tagged in production or not."
|
|
129
|
+
return self._get_connection_params()['production']
|
|
130
|
+
|
|
131
|
+
def init(self, name):
|
|
132
|
+
"""Called when creating a new repo.
|
|
133
|
+
Tries to read the connection parameters and then connect to
|
|
134
|
+
the database.
|
|
135
|
+
"""
|
|
136
|
+
try:
|
|
137
|
+
self.__init(name, get_release=False)
|
|
138
|
+
except FileNotFoundError:
|
|
139
|
+
pass
|
|
140
|
+
return self.__init_db()
|
|
141
|
+
|
|
142
|
+
def __init_db(self):
|
|
143
|
+
"""Tries to connect to the database. If unsuccessful, creates the
|
|
144
|
+
database end initializes it with half_orm_meta.
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
self.__model = Model(self.__name)
|
|
148
|
+
except OperationalError:
|
|
149
|
+
sys.stderr.write(f"The database '{self.__name}' does not exist.\n")
|
|
150
|
+
create = input('Do you want to create it (Y/n): ') or "y"
|
|
151
|
+
if create.upper() == 'Y':
|
|
152
|
+
self.execute_pg_command('createdb')
|
|
153
|
+
else:
|
|
154
|
+
utils.error(
|
|
155
|
+
f'Aborting! Please remove {self.__name} directory.\n', exit_code=1)
|
|
156
|
+
self.__model = Model(self.__name)
|
|
157
|
+
if self.__repo.devel:
|
|
158
|
+
try:
|
|
159
|
+
self.__model.get_relation_class('half_orm_meta.hop_release')
|
|
160
|
+
except UnknownRelation:
|
|
161
|
+
hop_init_sql_file = os.path.join(
|
|
162
|
+
HOP_PATH, 'patches', 'sql', 'half_orm_meta.sql')
|
|
163
|
+
self.execute_pg_command(
|
|
164
|
+
'psql', '-f', hop_init_sql_file, stdout=subprocess.DEVNULL)
|
|
165
|
+
self.__model.reconnect(reload=True)
|
|
166
|
+
self.__last_release = self.register_release(
|
|
167
|
+
major=0, minor=0, patch=0, changelog='Initial release')
|
|
168
|
+
return self(self.__name)
|
|
169
|
+
|
|
170
|
+
def execute_pg_command(self, *command_args):
|
|
171
|
+
"""Execute PostgreSQL command with instance's connection parameters."""
|
|
172
|
+
return self._execute_pg_command(
|
|
173
|
+
self.__name,
|
|
174
|
+
self._get_connection_params(),
|
|
175
|
+
*command_args
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def register_release(self, major, minor, patch, changelog=None):
|
|
179
|
+
"Register the release into half_orm_meta.hop_release"
|
|
180
|
+
return self.__model.get_relation_class('half_orm_meta.hop_release')(
|
|
181
|
+
major=major, minor=minor, patch=patch, changelog=changelog
|
|
182
|
+
).ho_insert()
|
|
183
|
+
|
|
184
|
+
def _generate_schema_sql(self, version: str, model_dir: Path) -> Path:
|
|
185
|
+
"""
|
|
186
|
+
Generate versioned schema SQL dump.
|
|
187
|
+
|
|
188
|
+
Creates model/schema-{version}.sql with current database structure
|
|
189
|
+
using pg_dump --schema-only. Creates model/metadata-{version}.sql
|
|
190
|
+
with half_orm_meta data using pg_dump --data-only.
|
|
191
|
+
Updates model/schema.sql symlink to point to the new version.
|
|
192
|
+
|
|
193
|
+
This method is used by:
|
|
194
|
+
- init-project: Generate initial schema-0.0.0.sql after database setup
|
|
195
|
+
- deploy-to-prod: Generate schema-X.Y.Z.sql after production deployment
|
|
196
|
+
|
|
197
|
+
Version History Strategy:
|
|
198
|
+
- Only production versions are saved (X.Y.Z)
|
|
199
|
+
- Stage and RC versions are NOT saved
|
|
200
|
+
- Hotfixes overwrite the base version (1.3.4-hotfix1 overwrites 1.3.4)
|
|
201
|
+
- Git history preserves old versions if needed
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
version: Version string (e.g., "0.0.0", "1.3.4", "2.0.0")
|
|
205
|
+
model_dir: Path to model/ directory where schema files are stored
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Path to generated schema file (model/schema-{version}.sql)
|
|
209
|
+
|
|
210
|
+
Raises:
|
|
211
|
+
DatabaseError: If pg_dump command fails
|
|
212
|
+
FileNotFoundError: If model_dir does not exist
|
|
213
|
+
PermissionError: If cannot write to model_dir or create symlink
|
|
214
|
+
ValueError: If version format is invalid
|
|
215
|
+
|
|
216
|
+
Examples:
|
|
217
|
+
# During init-project - create initial schema
|
|
218
|
+
from pathlib import Path
|
|
219
|
+
model_dir = Path("/project/model")
|
|
220
|
+
schema_path = database._generate_schema_sql("0.0.0", model_dir)
|
|
221
|
+
# → Creates model/schema-0.0.0.sql
|
|
222
|
+
# → Creates model/metadata-0.0.0.sql
|
|
223
|
+
# → Creates symlink model/schema.sql → schema-0.0.0.sql
|
|
224
|
+
# → Returns Path("/project/model/schema-0.0.0.sql")
|
|
225
|
+
|
|
226
|
+
# During deploy-to-prod - save production schema
|
|
227
|
+
schema_path = database._generate_schema_sql("1.3.4", model_dir)
|
|
228
|
+
# → Creates model/schema-1.3.4.sql
|
|
229
|
+
# → Creates model/metadata-1.3.4.sql
|
|
230
|
+
# → Updates symlink model/schema.sql → schema-1.3.4.sql
|
|
231
|
+
|
|
232
|
+
File Structure Created:
|
|
233
|
+
model/
|
|
234
|
+
├── schema.sql # Symlink to current version
|
|
235
|
+
├── schema-0.0.0.sql # Initial version (structure)
|
|
236
|
+
├── metadata-0.0.0.sql # Initial version (half_orm_meta data)
|
|
237
|
+
├── schema-1.0.0.sql # Production version (structure)
|
|
238
|
+
├── metadata-1.0.0.sql # Production version (half_orm_meta data)
|
|
239
|
+
├── schema-1.3.4.sql # Latest production version (current)
|
|
240
|
+
├── metadata-1.3.4.sql # Latest production version (current)
|
|
241
|
+
└── ...
|
|
242
|
+
|
|
243
|
+
Notes:
|
|
244
|
+
- Uses pg_dump --schema-only for structure (no data)
|
|
245
|
+
- Uses pg_dump --data-only for metadata (only half_orm_meta tables)
|
|
246
|
+
- Symlink is relative (schema.sql → schema-X.Y.Z.sql)
|
|
247
|
+
- No symlink for metadata (version deduced from schema.sql)
|
|
248
|
+
- Existing symlink is replaced atomically
|
|
249
|
+
- Version format should be X.Y.Z (semantic versioning)
|
|
250
|
+
"""
|
|
251
|
+
# Validate version format (X.Y.Z where X, Y, Z are integers)
|
|
252
|
+
version_pattern = r'^\d+\.\d+\.\d+$'
|
|
253
|
+
if not re.match(version_pattern, version):
|
|
254
|
+
raise ValueError(
|
|
255
|
+
f"Invalid version format: '{version}'. "
|
|
256
|
+
f"Expected semantic versioning (X.Y.Z, e.g., '1.3.4')"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Validate model_dir exists
|
|
260
|
+
if not model_dir.exists():
|
|
261
|
+
raise FileNotFoundError(
|
|
262
|
+
f"Model directory does not exist: {model_dir}"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if not model_dir.is_dir():
|
|
266
|
+
raise FileNotFoundError(
|
|
267
|
+
f"Model path exists but is not a directory: {model_dir}"
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Construct versioned schema file path
|
|
271
|
+
schema_file = model_dir / f"schema-{version}.sql"
|
|
272
|
+
|
|
273
|
+
# Generate schema dump using pg_dump
|
|
274
|
+
try:
|
|
275
|
+
self.execute_pg_command(
|
|
276
|
+
'pg_dump',
|
|
277
|
+
self.__name,
|
|
278
|
+
'--schema-only',
|
|
279
|
+
'-f',
|
|
280
|
+
str(schema_file)
|
|
281
|
+
)
|
|
282
|
+
except Exception as e:
|
|
283
|
+
raise Exception(f"Failed to generate schema SQL: {e}") from e
|
|
284
|
+
|
|
285
|
+
# Generate metadata dump (half_orm_meta data only)
|
|
286
|
+
metadata_file = model_dir / f"metadata-{version}.sql"
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
self.execute_pg_command(
|
|
290
|
+
'pg_dump',
|
|
291
|
+
self.__name,
|
|
292
|
+
'--data-only',
|
|
293
|
+
'--table=half_orm_meta.database',
|
|
294
|
+
'--table=half_orm_meta.hop_release',
|
|
295
|
+
'--table=half_orm_meta.hop_release_issue',
|
|
296
|
+
'-f',
|
|
297
|
+
str(metadata_file)
|
|
298
|
+
)
|
|
299
|
+
except Exception as e:
|
|
300
|
+
raise Exception(f"Failed to generate metadata SQL: {e}") from e
|
|
301
|
+
|
|
302
|
+
# Create or update symlink
|
|
303
|
+
symlink_path = model_dir / "schema.sql"
|
|
304
|
+
symlink_target = f"schema-{version}.sql" # Relative path
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
# Remove existing symlink if it exists
|
|
308
|
+
if symlink_path.exists() or symlink_path.is_symlink():
|
|
309
|
+
symlink_path.unlink()
|
|
310
|
+
|
|
311
|
+
# Create new symlink (relative)
|
|
312
|
+
symlink_path.symlink_to(symlink_target)
|
|
313
|
+
|
|
314
|
+
except PermissionError as e:
|
|
315
|
+
raise PermissionError(
|
|
316
|
+
f"Permission denied: cannot create symlink in {model_dir}"
|
|
317
|
+
) from e
|
|
318
|
+
except OSError as e:
|
|
319
|
+
raise OSError(
|
|
320
|
+
f"Failed to create symlink {symlink_path} → {symlink_target}: {e}"
|
|
321
|
+
) from e
|
|
322
|
+
|
|
323
|
+
return schema_file
|
|
324
|
+
|
|
325
|
+
@classmethod
|
|
326
|
+
def _save_configuration(cls, database_name, connection_params):
|
|
327
|
+
"""
|
|
328
|
+
Save connection parameters to configuration file.
|
|
329
|
+
|
|
330
|
+
Args:
|
|
331
|
+
database_name (str): PostgreSQL database name
|
|
332
|
+
connection_params (dict): Complete connection parameters
|
|
333
|
+
|
|
334
|
+
Returns:
|
|
335
|
+
str: Path to saved configuration file
|
|
336
|
+
|
|
337
|
+
Raises:
|
|
338
|
+
OSError: If configuration directory is not writable
|
|
339
|
+
"""
|
|
340
|
+
from configparser import ConfigParser
|
|
341
|
+
from half_orm.model import CONF_DIR
|
|
342
|
+
|
|
343
|
+
# Ensure configuration directory exists and is writable
|
|
344
|
+
if not os.path.exists(CONF_DIR):
|
|
345
|
+
os.makedirs(CONF_DIR, exist_ok=True)
|
|
346
|
+
|
|
347
|
+
if not os.access(CONF_DIR, os.W_OK):
|
|
348
|
+
raise OSError(f"Configuration directory {CONF_DIR} is not writable")
|
|
349
|
+
|
|
350
|
+
# Create configuration file path
|
|
351
|
+
config_file = os.path.join(CONF_DIR, database_name)
|
|
352
|
+
|
|
353
|
+
# Create and populate configuration
|
|
354
|
+
config = ConfigParser()
|
|
355
|
+
config.add_section('database')
|
|
356
|
+
config.set('database', 'name', database_name)
|
|
357
|
+
config.set('database', 'user', connection_params['user'])
|
|
358
|
+
config.set('database', 'password', connection_params['password'] or '')
|
|
359
|
+
config.set('database', 'host', connection_params['host'])
|
|
360
|
+
config.set('database', 'port', str(connection_params['port']))
|
|
361
|
+
config.set('database', 'production', str(connection_params['production']))
|
|
362
|
+
config.set('database', 'docker_container', connection_params.get('docker_container', ''))
|
|
363
|
+
|
|
364
|
+
# Write configuration file
|
|
365
|
+
with open(config_file, 'w') as f:
|
|
366
|
+
config.write(f)
|
|
367
|
+
|
|
368
|
+
return config_file
|
|
369
|
+
|
|
370
|
+
@classmethod
|
|
371
|
+
def _check_docker_available(cls) -> bool:
|
|
372
|
+
"""
|
|
373
|
+
Check if Docker is available on the system.
|
|
374
|
+
|
|
375
|
+
Verifies that Docker is installed and the Docker daemon is running
|
|
376
|
+
by executing 'docker --version'.
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
bool: True if Docker is available, False otherwise
|
|
380
|
+
|
|
381
|
+
Examples:
|
|
382
|
+
>>> Database._check_docker_available()
|
|
383
|
+
True # Docker is installed and running
|
|
384
|
+
|
|
385
|
+
>>> Database._check_docker_available()
|
|
386
|
+
False # Docker not installed or daemon not running
|
|
387
|
+
"""
|
|
388
|
+
try:
|
|
389
|
+
result = subprocess.run(
|
|
390
|
+
['docker', '--version'],
|
|
391
|
+
capture_output=True,
|
|
392
|
+
text=True,
|
|
393
|
+
check=True,
|
|
394
|
+
timeout=5
|
|
395
|
+
)
|
|
396
|
+
return result.returncode == 0
|
|
397
|
+
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
|
|
398
|
+
return False
|
|
399
|
+
|
|
400
|
+
@classmethod
|
|
401
|
+
def _check_docker_container_exists(cls, container_name: str) -> bool:
|
|
402
|
+
"""
|
|
403
|
+
Check if a Docker container exists (running or stopped).
|
|
404
|
+
|
|
405
|
+
Uses 'docker inspect' to verify container existence. This checks
|
|
406
|
+
for containers in any state (running, stopped, paused, etc.).
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
container_name (str): Name or ID of the Docker container
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
bool: True if container exists, False otherwise
|
|
413
|
+
|
|
414
|
+
Examples:
|
|
415
|
+
>>> Database._check_docker_container_exists('my_postgres')
|
|
416
|
+
True # Container exists
|
|
417
|
+
|
|
418
|
+
>>> Database._check_docker_container_exists('nonexistent')
|
|
419
|
+
False # Container does not exist
|
|
420
|
+
"""
|
|
421
|
+
try:
|
|
422
|
+
result = subprocess.run(
|
|
423
|
+
['docker', 'inspect', container_name],
|
|
424
|
+
capture_output=True,
|
|
425
|
+
text=True,
|
|
426
|
+
check=True,
|
|
427
|
+
timeout=5
|
|
428
|
+
)
|
|
429
|
+
return result.returncode == 0
|
|
430
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
431
|
+
return False
|
|
432
|
+
|
|
433
|
+
@classmethod
|
|
434
|
+
def _check_docker_container_running(cls, container_name: str) -> bool:
|
|
435
|
+
"""
|
|
436
|
+
Check if a Docker container is currently running.
|
|
437
|
+
|
|
438
|
+
Uses 'docker inspect' to check the container's running state.
|
|
439
|
+
Returns False if container doesn't exist or is stopped.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
container_name (str): Name or ID of the Docker container
|
|
443
|
+
|
|
444
|
+
Returns:
|
|
445
|
+
bool: True if container is running, False otherwise
|
|
446
|
+
|
|
447
|
+
Examples:
|
|
448
|
+
>>> Database._check_docker_container_running('my_postgres')
|
|
449
|
+
True # Container is running
|
|
450
|
+
|
|
451
|
+
>>> Database._check_docker_container_running('stopped_container')
|
|
452
|
+
False # Container exists but is stopped
|
|
453
|
+
"""
|
|
454
|
+
try:
|
|
455
|
+
result = subprocess.run(
|
|
456
|
+
['docker', 'inspect', '-f', '{{.State.Running}}', container_name],
|
|
457
|
+
capture_output=True,
|
|
458
|
+
text=True,
|
|
459
|
+
check=True,
|
|
460
|
+
timeout=5
|
|
461
|
+
)
|
|
462
|
+
# Docker inspect returns "true" or "false" as string
|
|
463
|
+
return result.stdout.strip().lower() == 'true'
|
|
464
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
465
|
+
return False
|
|
466
|
+
|
|
467
|
+
@classmethod
|
|
468
|
+
def _get_docker_container_info(cls, container_name: str) -> dict:
|
|
469
|
+
"""
|
|
470
|
+
Get detailed information about a Docker container.
|
|
471
|
+
|
|
472
|
+
Retrieves container status, ID, and other relevant information
|
|
473
|
+
using 'docker inspect'. Useful for debugging and error messages.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
container_name (str): Name or ID of the Docker container
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
dict: Container information with keys:
|
|
480
|
+
- exists (bool): Whether container exists
|
|
481
|
+
- running (bool): Whether container is running
|
|
482
|
+
- status (str): Container status (running, exited, etc.)
|
|
483
|
+
- id (str): Container ID (first 12 chars)
|
|
484
|
+
- name (str): Container name
|
|
485
|
+
|
|
486
|
+
Examples:
|
|
487
|
+
>>> info = Database._get_docker_container_info('my_postgres')
|
|
488
|
+
>>> print(info)
|
|
489
|
+
{
|
|
490
|
+
'exists': True,
|
|
491
|
+
'running': True,
|
|
492
|
+
'status': 'running',
|
|
493
|
+
'id': '3f8d9a2b1c4e',
|
|
494
|
+
'name': 'my_postgres'
|
|
495
|
+
}
|
|
496
|
+
"""
|
|
497
|
+
info = {
|
|
498
|
+
'exists': False,
|
|
499
|
+
'running': False,
|
|
500
|
+
'status': 'unknown',
|
|
501
|
+
'id': '',
|
|
502
|
+
'name': container_name
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
# Check if container exists
|
|
506
|
+
if not cls._check_docker_container_exists(container_name):
|
|
507
|
+
return info
|
|
508
|
+
|
|
509
|
+
info['exists'] = True
|
|
510
|
+
|
|
511
|
+
try:
|
|
512
|
+
# Get container status
|
|
513
|
+
result = subprocess.run(
|
|
514
|
+
['docker', 'inspect', '-f', '{{.State.Status}}', container_name],
|
|
515
|
+
capture_output=True,
|
|
516
|
+
text=True,
|
|
517
|
+
check=True,
|
|
518
|
+
timeout=5
|
|
519
|
+
)
|
|
520
|
+
info['status'] = result.stdout.strip()
|
|
521
|
+
info['running'] = info['status'] == 'running'
|
|
522
|
+
|
|
523
|
+
# Get container ID
|
|
524
|
+
result = subprocess.run(
|
|
525
|
+
['docker', 'inspect', '-f', '{{.Id}}', container_name],
|
|
526
|
+
capture_output=True,
|
|
527
|
+
text=True,
|
|
528
|
+
check=True,
|
|
529
|
+
timeout=5
|
|
530
|
+
)
|
|
531
|
+
info['id'] = result.stdout.strip()[:12] # First 12 chars
|
|
532
|
+
|
|
533
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
|
534
|
+
pass
|
|
535
|
+
|
|
536
|
+
return info
|
|
537
|
+
|
|
538
|
+
@classmethod
|
|
539
|
+
def _execute_native_pg_command(cls, database_name, connection_params, *command_args):
|
|
540
|
+
"""
|
|
541
|
+
Execute PostgreSQL command on native PostgreSQL installation.
|
|
542
|
+
|
|
543
|
+
This is the original implementation extracted from _execute_pg_command().
|
|
544
|
+
Executes PostgreSQL commands (psql, createdb, pg_dump, etc.) on a
|
|
545
|
+
native PostgreSQL installation using environment variables.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
database_name (str): PostgreSQL database name
|
|
549
|
+
connection_params (dict): Connection parameters (host, port, user, password)
|
|
550
|
+
*command_args: PostgreSQL command and arguments
|
|
551
|
+
|
|
552
|
+
Returns:
|
|
553
|
+
subprocess.CompletedProcess: Command execution result
|
|
554
|
+
|
|
555
|
+
Raises:
|
|
556
|
+
subprocess.CalledProcessError: If PostgreSQL command fails
|
|
557
|
+
|
|
558
|
+
Examples:
|
|
559
|
+
>>> Database._execute_native_pg_command(
|
|
560
|
+
... 'my_db',
|
|
561
|
+
... {'host': 'localhost', 'port': 5432, 'user': 'dev', 'password': 'secret'},
|
|
562
|
+
... 'createdb', 'my_db'
|
|
563
|
+
... )
|
|
564
|
+
"""
|
|
565
|
+
# Prepare environment variables for PostgreSQL commands
|
|
566
|
+
env = os.environ.copy()
|
|
567
|
+
env['PGUSER'] = connection_params['user']
|
|
568
|
+
env['PGHOST'] = connection_params['host']
|
|
569
|
+
env['PGPORT'] = str(connection_params['port'])
|
|
570
|
+
|
|
571
|
+
# Set password if provided (use PGPASSWORD environment variable)
|
|
572
|
+
if connection_params.get('password'):
|
|
573
|
+
env['PGPASSWORD'] = connection_params['password']
|
|
574
|
+
|
|
575
|
+
# Execute PostgreSQL command
|
|
576
|
+
result = subprocess.run(
|
|
577
|
+
command_args,
|
|
578
|
+
env=env,
|
|
579
|
+
capture_output=True,
|
|
580
|
+
text=True,
|
|
581
|
+
check=True
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
return result
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
@classmethod
|
|
588
|
+
def _execute_docker_pg_command(cls, container_name, database_name, connection_params, *command_args):
|
|
589
|
+
"""
|
|
590
|
+
Execute PostgreSQL command inside a Docker container.
|
|
591
|
+
|
|
592
|
+
Handles Docker-specific challenges:
|
|
593
|
+
- Adds -U option to avoid "role 'root' does not exist" errors
|
|
594
|
+
- Manages psql -f by reading files on host and passing via stdin
|
|
595
|
+
- Manages pg_dump -f by capturing stdout and writing to host
|
|
596
|
+
|
|
597
|
+
Args:
|
|
598
|
+
container_name (str): Docker container name
|
|
599
|
+
database_name (str): PostgreSQL database name
|
|
600
|
+
connection_params (dict): Connection parameters (user, password)
|
|
601
|
+
*command_args: PostgreSQL command and arguments
|
|
602
|
+
|
|
603
|
+
Returns:
|
|
604
|
+
subprocess.CompletedProcess: Command execution result
|
|
605
|
+
|
|
606
|
+
Raises:
|
|
607
|
+
DockerNotAvailableError: If Docker is not installed or not running
|
|
608
|
+
DockerContainerNotFoundError: If container does not exist
|
|
609
|
+
DockerContainerNotRunningError: If container exists but is stopped
|
|
610
|
+
subprocess.CalledProcessError: If PostgreSQL command fails
|
|
611
|
+
|
|
612
|
+
Examples:
|
|
613
|
+
# psql -f (reads file on host, passes via stdin)
|
|
614
|
+
>>> Database._execute_docker_pg_command(
|
|
615
|
+
... 'my_postgres', 'my_db',
|
|
616
|
+
... {'user': 'postgres', 'password': 'secret'},
|
|
617
|
+
... 'psql', '-d', 'my_db', '-f', '/path/to/schema.sql'
|
|
618
|
+
... )
|
|
619
|
+
|
|
620
|
+
# pg_dump -f (captures stdout, writes to host)
|
|
621
|
+
>>> Database._execute_docker_pg_command(
|
|
622
|
+
... 'my_postgres', 'my_db',
|
|
623
|
+
... {'user': 'postgres', 'password': 'secret'},
|
|
624
|
+
... 'pg_dump', 'my_db', '--schema-only', '-f', '/path/to/dump.sql'
|
|
625
|
+
... )
|
|
626
|
+
"""
|
|
627
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
628
|
+
# STEP 1: Check Docker availability
|
|
629
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
630
|
+
if not cls._check_docker_available():
|
|
631
|
+
raise DockerNotAvailableError(
|
|
632
|
+
"Docker is not installed or not running.\n"
|
|
633
|
+
"Install Docker: https://docs.docker.com/get-docker/"
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
637
|
+
# STEP 2: Check container exists
|
|
638
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
639
|
+
if not cls._check_docker_container_exists(container_name):
|
|
640
|
+
raise DockerContainerNotFoundError(
|
|
641
|
+
f"Docker container '{container_name}' not found.\n"
|
|
642
|
+
f"Run: docker ps -a # to list all containers\n"
|
|
643
|
+
f"Or create a new PostgreSQL container:\n"
|
|
644
|
+
f" docker run -d --name {container_name} -e POSTGRES_PASSWORD=postgres postgres:17"
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
648
|
+
# STEP 3: Check container is running
|
|
649
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
650
|
+
if not cls._check_docker_container_running(container_name):
|
|
651
|
+
container_info = cls._get_docker_container_info(container_name)
|
|
652
|
+
raise DockerContainerNotRunningError(
|
|
653
|
+
f"Docker container '{container_name}' exists but is not running.\n"
|
|
654
|
+
f"Status: {container_info['status']}\n"
|
|
655
|
+
f"Run: docker start {container_name}"
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
659
|
+
# STEP 4: Handle file operations (psql -f, pg_dump -f)
|
|
660
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
661
|
+
command_list = list(command_args)
|
|
662
|
+
modified_command = command_list.copy()
|
|
663
|
+
sql_input = None # For psql -f (stdin)
|
|
664
|
+
output_file = None # For pg_dump -f (stdout redirect)
|
|
665
|
+
|
|
666
|
+
command_name = command_list[0] if len(command_list) > 0 else ''
|
|
667
|
+
|
|
668
|
+
# ────────────────────────────────────────────────────────────────────
|
|
669
|
+
# Case 1: psql -f <file> → Read file and pass via stdin
|
|
670
|
+
# ────────────────────────────────────────────────────────────────────
|
|
671
|
+
if command_name == 'psql':
|
|
672
|
+
try:
|
|
673
|
+
f_index = command_list.index('-f')
|
|
674
|
+
if f_index + 1 < len(command_list):
|
|
675
|
+
host_file_path = command_list[f_index + 1]
|
|
676
|
+
|
|
677
|
+
# Read SQL file content on host
|
|
678
|
+
with open(host_file_path, 'r', encoding='utf-8') as f:
|
|
679
|
+
sql_input = f.read()
|
|
680
|
+
|
|
681
|
+
# Remove -f option from command (will use stdin)
|
|
682
|
+
modified_command = command_list[:f_index] + command_list[f_index+2:]
|
|
683
|
+
except (ValueError, FileNotFoundError, OSError):
|
|
684
|
+
# -f not found or file read failed, use original command
|
|
685
|
+
pass
|
|
686
|
+
|
|
687
|
+
# ────────────────────────────────────────────────────────────────────
|
|
688
|
+
# Case 2: pg_dump -f <file> → Remove -f and capture stdout
|
|
689
|
+
# ────────────────────────────────────────────────────────────────────
|
|
690
|
+
elif command_name == 'pg_dump':
|
|
691
|
+
try:
|
|
692
|
+
f_index = command_list.index('-f')
|
|
693
|
+
if f_index + 1 < len(command_list):
|
|
694
|
+
output_file = command_list[f_index + 1]
|
|
695
|
+
|
|
696
|
+
# Remove -f option (will capture stdout)
|
|
697
|
+
modified_command = command_list[:f_index] + command_list[f_index+2:]
|
|
698
|
+
except ValueError:
|
|
699
|
+
# -f not found, use original command
|
|
700
|
+
pass
|
|
701
|
+
|
|
702
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
703
|
+
# STEP 5: Prepare PostgreSQL command with -U option
|
|
704
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
705
|
+
# CRITICAL: Add -U <user> to avoid "role 'root' does not exist" error
|
|
706
|
+
# docker exec runs as root, but we need PostgreSQL user
|
|
707
|
+
|
|
708
|
+
pg_user = connection_params['user']
|
|
709
|
+
|
|
710
|
+
if len(modified_command) > 0:
|
|
711
|
+
pg_command = modified_command[0] # e.g., 'createdb', 'psql', 'pg_dump'
|
|
712
|
+
command_args_rest = modified_command[1:]
|
|
713
|
+
|
|
714
|
+
# Insert -U <user> after command name
|
|
715
|
+
final_command = [pg_command, '-U', pg_user] + command_args_rest
|
|
716
|
+
else:
|
|
717
|
+
final_command = modified_command
|
|
718
|
+
|
|
719
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
720
|
+
# STEP 6: Prepare Docker exec command
|
|
721
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
722
|
+
docker_cmd = ['docker', 'exec', '-i', container_name] + final_command
|
|
723
|
+
|
|
724
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
725
|
+
# STEP 7: Prepare environment variables
|
|
726
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
727
|
+
env = os.environ.copy()
|
|
728
|
+
|
|
729
|
+
# Set password if provided (PGPASSWORD for authentication)
|
|
730
|
+
if connection_params.get('password'):
|
|
731
|
+
env['PGPASSWORD'] = connection_params['password']
|
|
732
|
+
|
|
733
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
734
|
+
# STEP 8: Execute command with appropriate I/O handling
|
|
735
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
736
|
+
|
|
737
|
+
if sql_input:
|
|
738
|
+
# ──────────────────────────────────────────────────────────────
|
|
739
|
+
# psql -f: Pass SQL content via stdin
|
|
740
|
+
# ──────────────────────────────────────────────────────────────
|
|
741
|
+
result = subprocess.run(
|
|
742
|
+
docker_cmd,
|
|
743
|
+
env=env,
|
|
744
|
+
input=sql_input,
|
|
745
|
+
capture_output=True,
|
|
746
|
+
text=True,
|
|
747
|
+
check=True
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
elif output_file:
|
|
751
|
+
# ──────────────────────────────────────────────────────────────
|
|
752
|
+
# pg_dump -f: Capture stdout and write to host file
|
|
753
|
+
# ──────────────────────────────────────────────────────────────
|
|
754
|
+
result = subprocess.run(
|
|
755
|
+
docker_cmd,
|
|
756
|
+
env=env,
|
|
757
|
+
capture_output=True,
|
|
758
|
+
text=True,
|
|
759
|
+
check=True
|
|
760
|
+
)
|
|
761
|
+
|
|
762
|
+
# Write stdout to output file on host
|
|
763
|
+
with open(output_file, 'w', encoding='utf-8') as f:
|
|
764
|
+
f.write(result.stdout)
|
|
765
|
+
|
|
766
|
+
else:
|
|
767
|
+
# ──────────────────────────────────────────────────────────────
|
|
768
|
+
# Standard execution (no file operations)
|
|
769
|
+
# ──────────────────────────────────────────────────────────────
|
|
770
|
+
result = subprocess.run(
|
|
771
|
+
docker_cmd,
|
|
772
|
+
env=env,
|
|
773
|
+
capture_output=True,
|
|
774
|
+
text=True,
|
|
775
|
+
check=True
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
return result
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
@classmethod
|
|
782
|
+
def _execute_pg_command(cls, database_name, connection_params, *command_args):
|
|
783
|
+
"""
|
|
784
|
+
Execute PostgreSQL command with connection parameters (native or Docker).
|
|
785
|
+
|
|
786
|
+
Routes command execution to either native PostgreSQL or Docker container
|
|
787
|
+
based on the presence of 'docker_container' in connection_params.
|
|
788
|
+
|
|
789
|
+
**Mode Detection**:
|
|
790
|
+
- If docker_container is present and non-empty → Docker mode
|
|
791
|
+
- Otherwise → Native PostgreSQL mode
|
|
792
|
+
|
|
793
|
+
Args:
|
|
794
|
+
database_name (str): PostgreSQL database name
|
|
795
|
+
connection_params (dict): Connection parameters including optional docker_container
|
|
796
|
+
*command_args: PostgreSQL command arguments
|
|
797
|
+
|
|
798
|
+
Returns:
|
|
799
|
+
subprocess.CompletedProcess: Command execution result
|
|
800
|
+
|
|
801
|
+
Raises:
|
|
802
|
+
DockerNotAvailableError: If Docker mode but Docker not available
|
|
803
|
+
DockerContainerNotFoundError: If Docker mode but container not found
|
|
804
|
+
DockerContainerNotRunningError: If Docker mode but container stopped
|
|
805
|
+
subprocess.CalledProcessError: If PostgreSQL command fails
|
|
806
|
+
|
|
807
|
+
Examples:
|
|
808
|
+
# Native PostgreSQL (existing behavior)
|
|
809
|
+
>>> Database._execute_pg_command(
|
|
810
|
+
... 'my_db',
|
|
811
|
+
... {'host': 'localhost', 'port': 5432, 'user': 'dev', 'password': 'secret'},
|
|
812
|
+
... 'createdb', 'my_db'
|
|
813
|
+
... )
|
|
814
|
+
|
|
815
|
+
# Docker PostgreSQL (new behavior)
|
|
816
|
+
>>> Database._execute_pg_command(
|
|
817
|
+
... 'my_db',
|
|
818
|
+
... {'user': 'postgres', 'password': 'secret', 'docker_container': 'my_postgres'},
|
|
819
|
+
... 'createdb', 'my_db'
|
|
820
|
+
... )
|
|
821
|
+
"""
|
|
822
|
+
# Detect execution mode based on docker_container presence
|
|
823
|
+
docker_container = connection_params.get('docker_container', '')
|
|
824
|
+
|
|
825
|
+
if docker_container:
|
|
826
|
+
# Docker mode: Execute command inside Docker container
|
|
827
|
+
return cls._execute_docker_pg_command(
|
|
828
|
+
docker_container,
|
|
829
|
+
database_name,
|
|
830
|
+
connection_params,
|
|
831
|
+
*command_args
|
|
832
|
+
)
|
|
833
|
+
else:
|
|
834
|
+
# Native mode: Execute command on native PostgreSQL
|
|
835
|
+
return cls._execute_native_pg_command(
|
|
836
|
+
database_name,
|
|
837
|
+
connection_params,
|
|
838
|
+
*command_args
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
@classmethod
|
|
842
|
+
def setup_database(cls, database_name, connection_options, create_db=True, add_metadata=False):
|
|
843
|
+
"""
|
|
844
|
+
Configure database connection and install half-orm metadata schemas.
|
|
845
|
+
|
|
846
|
+
Replaces the interactive __init_db() method with a non-interactive version
|
|
847
|
+
that accepts connection parameters from CLI options or prompts for missing ones.
|
|
848
|
+
|
|
849
|
+
**AUTOMATIC METADATA INSTALLATION**: If create_db=True, metadata is automatically
|
|
850
|
+
installed for the newly created database (add_metadata becomes True automatically).
|
|
851
|
+
|
|
852
|
+
Args:
|
|
853
|
+
database_name (str): PostgreSQL database name
|
|
854
|
+
connection_options (dict): Connection parameters from CLI
|
|
855
|
+
- host (str): PostgreSQL host (default: localhost)
|
|
856
|
+
- port (int): PostgreSQL port (default: 5432)
|
|
857
|
+
- user (str): Database user (default: $USER)
|
|
858
|
+
- password (str): Database password (prompts if None)
|
|
859
|
+
- production (bool): Production environment flag
|
|
860
|
+
create_db (bool): Create database if it doesn't exist
|
|
861
|
+
add_metadata (bool): Add half_orm_meta schemas to existing database
|
|
862
|
+
(automatically True if create_db=True)
|
|
863
|
+
|
|
864
|
+
Returns:
|
|
865
|
+
str: Path to saved configuration file
|
|
866
|
+
|
|
867
|
+
Raises:
|
|
868
|
+
DatabaseConnectionError: If connection to PostgreSQL fails
|
|
869
|
+
DatabaseCreationError: If database creation fails
|
|
870
|
+
MetadataInstallationError: If metadata schema installation fails
|
|
871
|
+
|
|
872
|
+
Process Flow:
|
|
873
|
+
1. Parameter Collection: Use provided options or prompt for missing ones
|
|
874
|
+
2. Connection Test: Verify PostgreSQL connection with provided credentials
|
|
875
|
+
3. Database Setup: Create database if create_db=True, or connect to existing
|
|
876
|
+
4. Metadata Installation: Add half_orm_meta and half_orm_meta.view schemas
|
|
877
|
+
- Automatically installed for newly created databases (create_db=True)
|
|
878
|
+
- Manually requested for existing databases (add_metadata=True)
|
|
879
|
+
5. Configuration Save: Store connection parameters in configuration file
|
|
880
|
+
6. Initial Release: Register version 0.0.0 in metadata
|
|
881
|
+
|
|
882
|
+
Examples:
|
|
883
|
+
# Create new database - metadata automatically installed
|
|
884
|
+
Database.setup_database(
|
|
885
|
+
database_name="my_blog_db",
|
|
886
|
+
connection_options={'host': 'localhost', 'user': 'dev', 'password': 'secret'},
|
|
887
|
+
create_db=True # add_metadata becomes True automatically
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
# Add metadata to existing database manually
|
|
891
|
+
Database.setup_database(
|
|
892
|
+
database_name="legacy_db",
|
|
893
|
+
connection_options={'host': 'prod.db.com', 'user': 'admin'},
|
|
894
|
+
create_db=False,
|
|
895
|
+
add_metadata=True # Explicit metadata installation
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
# Connect to existing database without metadata (sync-only mode)
|
|
899
|
+
Database.setup_database(
|
|
900
|
+
database_name="readonly_db",
|
|
901
|
+
connection_options={'host': 'localhost'},
|
|
902
|
+
create_db=False,
|
|
903
|
+
add_metadata=False # No metadata - sync-only mode
|
|
904
|
+
)
|
|
905
|
+
"""
|
|
906
|
+
# Step 1: Validate input parameters
|
|
907
|
+
cls._validate_parameters(database_name, connection_options)
|
|
908
|
+
|
|
909
|
+
# Step 2: Collect connection parameters
|
|
910
|
+
complete_params = cls._collect_connection_params(database_name, connection_options)
|
|
911
|
+
|
|
912
|
+
# Step 3: Save configuration to file
|
|
913
|
+
config_file = cls._save_configuration(database_name, complete_params)
|
|
914
|
+
|
|
915
|
+
# Step 4: Test database connection (create if needed)
|
|
916
|
+
database_created = False # Track if we created a new database
|
|
917
|
+
|
|
918
|
+
try:
|
|
919
|
+
model = Model(database_name)
|
|
920
|
+
except OperationalError:
|
|
921
|
+
if create_db:
|
|
922
|
+
# Create database using PostgreSQL createdb command
|
|
923
|
+
cls._execute_pg_command(database_name, complete_params, 'createdb', database_name)
|
|
924
|
+
database_created = True # Mark that we created the database
|
|
925
|
+
# Retry connection after creation
|
|
926
|
+
model = Model(database_name)
|
|
927
|
+
else:
|
|
928
|
+
raise OperationalError(f"Database '{database_name}' does not exist and create_db=False")
|
|
929
|
+
|
|
930
|
+
# Step 5: Install metadata if requested OR if database was newly created
|
|
931
|
+
# AUTOMATIC BEHAVIOR: newly created databases automatically get metadata
|
|
932
|
+
should_install_metadata = add_metadata or database_created
|
|
933
|
+
|
|
934
|
+
if should_install_metadata:
|
|
935
|
+
try:
|
|
936
|
+
model.get_relation_class('half_orm_meta.hop_release')
|
|
937
|
+
# Metadata already exists - skip installation
|
|
938
|
+
except UnknownRelation:
|
|
939
|
+
# Install metadata schemas
|
|
940
|
+
hop_init_sql_file = os.path.join(HOP_PATH, 'patches', 'sql', 'half_orm_meta.sql')
|
|
941
|
+
cls._execute_pg_command(
|
|
942
|
+
database_name,
|
|
943
|
+
complete_params,
|
|
944
|
+
'psql',
|
|
945
|
+
'-d', database_name,
|
|
946
|
+
'-f', hop_init_sql_file
|
|
947
|
+
)
|
|
948
|
+
model.reconnect(reload=True)
|
|
949
|
+
|
|
950
|
+
# Register initial release 0.0.0
|
|
951
|
+
release_class = model.get_relation_class('half_orm_meta.hop_release')
|
|
952
|
+
release_class(
|
|
953
|
+
major=0, minor=0, patch=0, changelog='Initial release'
|
|
954
|
+
).ho_insert()
|
|
955
|
+
|
|
956
|
+
return config_file
|
|
957
|
+
|
|
958
|
+
@classmethod
|
|
959
|
+
def _validate_parameters(cls, database_name, connection_options):
|
|
960
|
+
"""
|
|
961
|
+
Validate input parameters for database setup.
|
|
962
|
+
|
|
963
|
+
Args:
|
|
964
|
+
database_name (str): PostgreSQL database name
|
|
965
|
+
connection_options (dict): Connection parameters from CLI
|
|
966
|
+
|
|
967
|
+
Raises:
|
|
968
|
+
ValueError: If database_name is invalid
|
|
969
|
+
TypeError: If connection_options is not a dict
|
|
970
|
+
|
|
971
|
+
Returns:
|
|
972
|
+
None: Parameters are valid
|
|
973
|
+
|
|
974
|
+
Examples:
|
|
975
|
+
# Valid parameters
|
|
976
|
+
Database._validate_parameters("my_db", {'host': 'localhost'})
|
|
977
|
+
|
|
978
|
+
# Invalid database name
|
|
979
|
+
Database._validate_parameters("", {}) # Raises ValueError
|
|
980
|
+
Database._validate_parameters(None, {}) # Raises ValueError
|
|
981
|
+
|
|
982
|
+
# Invalid connection options
|
|
983
|
+
Database._validate_parameters("my_db", None) # Raises TypeError
|
|
984
|
+
"""
|
|
985
|
+
# Validate database_name
|
|
986
|
+
if database_name is None:
|
|
987
|
+
raise ValueError("Database name cannot be None")
|
|
988
|
+
|
|
989
|
+
if not isinstance(database_name, str):
|
|
990
|
+
raise ValueError(f"Database name must be a string, got {type(database_name).__name__}")
|
|
991
|
+
|
|
992
|
+
if database_name.strip() == "":
|
|
993
|
+
raise ValueError("Database name cannot be empty")
|
|
994
|
+
|
|
995
|
+
# Basic name format validation (PostgreSQL identifier rules)
|
|
996
|
+
database_name = database_name.strip()
|
|
997
|
+
if not database_name.replace('_', '').replace('-', '').isalnum():
|
|
998
|
+
raise ValueError(f"Database name '{database_name}' contains invalid characters. Use only letters, numbers, underscore, and hyphen.")
|
|
999
|
+
|
|
1000
|
+
if database_name[0].isdigit():
|
|
1001
|
+
raise ValueError(f"Database name '{database_name}' cannot start with a digit")
|
|
1002
|
+
|
|
1003
|
+
# Validate connection_options
|
|
1004
|
+
if connection_options is None:
|
|
1005
|
+
raise TypeError("Connection options cannot be None")
|
|
1006
|
+
|
|
1007
|
+
if not isinstance(connection_options, dict):
|
|
1008
|
+
raise TypeError(f"Connection options must be a dictionary, got {type(connection_options).__name__}")
|
|
1009
|
+
|
|
1010
|
+
# Expected option keys (some may be None/missing for interactive prompts)
|
|
1011
|
+
expected_keys = {'host', 'port', 'user', 'password', 'production', 'docker_container'}
|
|
1012
|
+
provided_keys = set(connection_options.keys())
|
|
1013
|
+
|
|
1014
|
+
# Check for unexpected keys
|
|
1015
|
+
unexpected_keys = provided_keys - expected_keys
|
|
1016
|
+
if unexpected_keys:
|
|
1017
|
+
raise ValueError(f"Unexpected connection options: {sorted(unexpected_keys)}. Expected: {sorted(expected_keys)}")
|
|
1018
|
+
|
|
1019
|
+
# Validate port if provided
|
|
1020
|
+
if 'port' in connection_options and connection_options['port'] is not None:
|
|
1021
|
+
port = connection_options['port']
|
|
1022
|
+
if not isinstance(port, int) or port <= 0 or port > 65535:
|
|
1023
|
+
raise ValueError(f"Port must be an integer between 1 and 65535, got {port}")
|
|
1024
|
+
|
|
1025
|
+
# Validate production flag if provided
|
|
1026
|
+
if 'production' in connection_options and connection_options['production'] is not None:
|
|
1027
|
+
production = connection_options['production']
|
|
1028
|
+
if not isinstance(production, bool):
|
|
1029
|
+
raise ValueError(f"Production flag must be boolean, got {type(production).__name__}")
|
|
1030
|
+
|
|
1031
|
+
@classmethod
|
|
1032
|
+
def _collect_connection_params(cls, database_name, connection_options):
|
|
1033
|
+
"""
|
|
1034
|
+
Collect missing connection parameters interactively.
|
|
1035
|
+
|
|
1036
|
+
Takes partial connection parameters from CLI options and prompts
|
|
1037
|
+
interactively for any missing or None values. Applies halfORM
|
|
1038
|
+
standard defaults where appropriate.
|
|
1039
|
+
|
|
1040
|
+
Args:
|
|
1041
|
+
database_name (str): PostgreSQL database name for context
|
|
1042
|
+
connection_options (dict): Partial connection parameters from CLI
|
|
1043
|
+
- host (str|None): PostgreSQL host
|
|
1044
|
+
- port (int|None): PostgreSQL port
|
|
1045
|
+
- user (str|None): Database user
|
|
1046
|
+
- password (str|None): Database password
|
|
1047
|
+
- production (bool|None): Production environment flag
|
|
1048
|
+
|
|
1049
|
+
Returns:
|
|
1050
|
+
dict: Complete connection parameters ready for DbConn initialization
|
|
1051
|
+
- host (str): PostgreSQL host (default: 'localhost')
|
|
1052
|
+
- port (int): PostgreSQL port (default: 5432)
|
|
1053
|
+
- user (str): Database user (default: $USER env var)
|
|
1054
|
+
- password (str): Database password (prompted if None)
|
|
1055
|
+
- production (bool): Production flag (default: False)
|
|
1056
|
+
|
|
1057
|
+
Raises:
|
|
1058
|
+
KeyboardInterrupt: If user cancels interactive prompts
|
|
1059
|
+
EOFError: If input stream is closed during prompts
|
|
1060
|
+
|
|
1061
|
+
Interactive Behavior:
|
|
1062
|
+
- Only prompts for missing/None parameters
|
|
1063
|
+
- Shows current defaults in prompts: "Host (localhost): "
|
|
1064
|
+
- Uses getpass for secure password input
|
|
1065
|
+
- Allows empty input to accept defaults
|
|
1066
|
+
- Confirms production flag if True
|
|
1067
|
+
|
|
1068
|
+
Examples:
|
|
1069
|
+
# Complete parameters provided - no prompts
|
|
1070
|
+
complete = Database._collect_connection_params(
|
|
1071
|
+
"my_db",
|
|
1072
|
+
{'host': 'localhost', 'port': 5432, 'user': 'dev', 'password': 'secret', 'production': False}
|
|
1073
|
+
)
|
|
1074
|
+
# Returns: same dict (no interaction needed)
|
|
1075
|
+
|
|
1076
|
+
# Missing user and password - prompts interactively
|
|
1077
|
+
complete = Database._collect_connection_params(
|
|
1078
|
+
"my_db",
|
|
1079
|
+
{'host': 'localhost', 'port': 5432, 'user': None, 'password': None, 'production': False}
|
|
1080
|
+
)
|
|
1081
|
+
# Prompts: "User (current_user): " and "Password: [hidden]"
|
|
1082
|
+
# Returns: {'host': 'localhost', 'port': 5432, 'user': 'prompted_user', 'password': 'prompted_pass', 'production': False}
|
|
1083
|
+
|
|
1084
|
+
# Only host provided - prompts for missing with defaults
|
|
1085
|
+
complete = Database._collect_connection_params(
|
|
1086
|
+
"my_db",
|
|
1087
|
+
{'host': 'prod.db.com'}
|
|
1088
|
+
)
|
|
1089
|
+
# Prompts: "Port (5432): ", "User (current_user): ", "Password: "
|
|
1090
|
+
# Returns: complete dict with provided host and prompted/default values
|
|
1091
|
+
|
|
1092
|
+
# Production flag confirmation
|
|
1093
|
+
complete = Database._collect_connection_params(
|
|
1094
|
+
"prod_db",
|
|
1095
|
+
{'host': 'prod.db.com', 'production': True}
|
|
1096
|
+
)
|
|
1097
|
+
# Prompts: "Production environment (True): " for confirmation
|
|
1098
|
+
# Returns: dict with confirmed production setting
|
|
1099
|
+
"""
|
|
1100
|
+
import getpass
|
|
1101
|
+
import os
|
|
1102
|
+
|
|
1103
|
+
# Create a copy to avoid modifying the original
|
|
1104
|
+
complete_params = connection_options.copy()
|
|
1105
|
+
|
|
1106
|
+
# Interactive prompts for None values BEFORE applying defaults
|
|
1107
|
+
print(f"Connection parameters for database '{database_name}':")
|
|
1108
|
+
|
|
1109
|
+
# Prompt for user if None
|
|
1110
|
+
if complete_params.get('user') is None:
|
|
1111
|
+
default_user = os.environ.get('USER', 'postgres')
|
|
1112
|
+
user_input = input(f"User ({default_user}): ").strip()
|
|
1113
|
+
complete_params['user'] = user_input if user_input else default_user
|
|
1114
|
+
|
|
1115
|
+
# Prompt for password if None (always prompt - security requirement)
|
|
1116
|
+
if complete_params.get('password') is None:
|
|
1117
|
+
password_input = getpass.getpass("Password: ")
|
|
1118
|
+
if password_input == '':
|
|
1119
|
+
# Empty password - assume trust/ident authentication
|
|
1120
|
+
complete_params['password'] = None # Explicitly None for trust mode
|
|
1121
|
+
complete_params['host'] = '' # Local socket connection
|
|
1122
|
+
complete_params['port'] = '' # No port for local socket
|
|
1123
|
+
else:
|
|
1124
|
+
complete_params['password'] = password_input
|
|
1125
|
+
|
|
1126
|
+
# Prompt for host if None
|
|
1127
|
+
if complete_params.get('host') is None:
|
|
1128
|
+
host_input = input("Host (localhost): ").strip()
|
|
1129
|
+
complete_params['host'] = host_input if host_input else 'localhost'
|
|
1130
|
+
|
|
1131
|
+
# Prompt for port if None
|
|
1132
|
+
if complete_params.get('port') is None:
|
|
1133
|
+
port_input = input("Port (5432): ").strip()
|
|
1134
|
+
if port_input:
|
|
1135
|
+
try:
|
|
1136
|
+
complete_params['port'] = int(port_input)
|
|
1137
|
+
except ValueError:
|
|
1138
|
+
raise ValueError(f"Invalid port number: {port_input}")
|
|
1139
|
+
else:
|
|
1140
|
+
complete_params['port'] = 5432
|
|
1141
|
+
|
|
1142
|
+
# Apply defaults for still missing parameters (no prompts needed)
|
|
1143
|
+
if complete_params.get('host') is None:
|
|
1144
|
+
complete_params['host'] = 'localhost'
|
|
1145
|
+
|
|
1146
|
+
if complete_params.get('port') is None:
|
|
1147
|
+
complete_params['port'] = 5432
|
|
1148
|
+
|
|
1149
|
+
if complete_params.get('user') is None:
|
|
1150
|
+
complete_params['user'] = os.environ.get('USER', 'postgres')
|
|
1151
|
+
|
|
1152
|
+
if complete_params.get('production') is None:
|
|
1153
|
+
complete_params['production'] = False
|
|
1154
|
+
|
|
1155
|
+
# Prompt for production confirmation if True (security measure)
|
|
1156
|
+
if complete_params.get('production') is True:
|
|
1157
|
+
prod_input = input(f"Production environment (True): ").strip().lower()
|
|
1158
|
+
if prod_input and prod_input not in ['true', 't', 'yes', 'y', '1']:
|
|
1159
|
+
complete_params['production'] = False
|
|
1160
|
+
|
|
1161
|
+
return complete_params
|
|
1162
|
+
|
|
1163
|
+
@classmethod
|
|
1164
|
+
def _load_configuration(cls, database_name):
|
|
1165
|
+
"""
|
|
1166
|
+
Load existing database configuration file, replacing DbConn functionality.
|
|
1167
|
+
|
|
1168
|
+
Reads halfORM configuration file and returns connection parameters as a dictionary.
|
|
1169
|
+
This method completely replaces DbConn.__init() logic, supporting both minimal
|
|
1170
|
+
configurations (PostgreSQL trust mode) and complete parameter sets.
|
|
1171
|
+
|
|
1172
|
+
Args:
|
|
1173
|
+
database_name (str): Name of the database to load configuration for
|
|
1174
|
+
|
|
1175
|
+
Returns:
|
|
1176
|
+
dict | None: Connection parameters dictionary with standardized keys:
|
|
1177
|
+
- name (str): Database name (always present)
|
|
1178
|
+
- user (str): Database user (defaults to $USER environment variable)
|
|
1179
|
+
- password (str): Database password (empty string if not set)
|
|
1180
|
+
- host (str): Database host (empty string for Unix socket, 'localhost' otherwise)
|
|
1181
|
+
- port (int): Database port (5432 if not specified)
|
|
1182
|
+
- production (bool): Production environment flag (defaults to False)
|
|
1183
|
+
Returns None if configuration file doesn't exist.
|
|
1184
|
+
|
|
1185
|
+
Raises:
|
|
1186
|
+
FileNotFoundError: If CONF_DIR doesn't exist or isn't accessible
|
|
1187
|
+
PermissionError: If configuration file exists but isn't readable
|
|
1188
|
+
ValueError: If configuration file format is invalid or corrupted
|
|
1189
|
+
|
|
1190
|
+
Examples:
|
|
1191
|
+
# Complete configuration file
|
|
1192
|
+
config = Database._load_configuration("production_db")
|
|
1193
|
+
# Returns: {'name': 'production_db', 'user': 'app_user', 'password': 'secret',
|
|
1194
|
+
# 'host': 'db.company.com', 'port': 5432, 'production': True}
|
|
1195
|
+
|
|
1196
|
+
# Minimal trust mode configuration (only name=database_name)
|
|
1197
|
+
config = Database._load_configuration("local_dev")
|
|
1198
|
+
# Returns: {'name': 'local_dev', 'user': 'joel', 'password': '',
|
|
1199
|
+
# 'host': '', 'port': 5432, 'production': False}
|
|
1200
|
+
|
|
1201
|
+
# Non-existent configuration
|
|
1202
|
+
config = Database._load_configuration("unknown_db")
|
|
1203
|
+
# Returns: None
|
|
1204
|
+
|
|
1205
|
+
Migration Notes:
|
|
1206
|
+
- Completely replaces DbConn.__init() and DbConn.__init logic
|
|
1207
|
+
- Maintains backward compatibility with existing config files
|
|
1208
|
+
- Standardizes return format (int for port, bool for production)
|
|
1209
|
+
- Integrates PostgreSQL trust mode defaults directly into Database class
|
|
1210
|
+
- Eliminates external DbConn dependency while preserving all functionality
|
|
1211
|
+
"""
|
|
1212
|
+
import os
|
|
1213
|
+
from configparser import ConfigParser
|
|
1214
|
+
from half_orm.model import CONF_DIR
|
|
1215
|
+
|
|
1216
|
+
# Check if configuration directory exists
|
|
1217
|
+
if not os.path.exists(CONF_DIR):
|
|
1218
|
+
raise FileNotFoundError(f"Configuration directory {CONF_DIR} doesn't exist")
|
|
1219
|
+
|
|
1220
|
+
# Build configuration file path
|
|
1221
|
+
config_file = os.path.join(CONF_DIR, database_name)
|
|
1222
|
+
|
|
1223
|
+
# Return None if configuration file doesn't exist
|
|
1224
|
+
if not os.path.exists(config_file):
|
|
1225
|
+
return None
|
|
1226
|
+
|
|
1227
|
+
# Check if file is readable before attempting to parse
|
|
1228
|
+
if not os.access(config_file, os.R_OK):
|
|
1229
|
+
raise PermissionError(f"Configuration file {config_file} is not readable")
|
|
1230
|
+
|
|
1231
|
+
# Read configuration file
|
|
1232
|
+
config = ConfigParser()
|
|
1233
|
+
try:
|
|
1234
|
+
config.read(config_file)
|
|
1235
|
+
except Exception as e:
|
|
1236
|
+
raise ValueError(f"Configuration file format is invalid: {e}")
|
|
1237
|
+
|
|
1238
|
+
# Check if [database] section exists
|
|
1239
|
+
if not config.has_section('database'):
|
|
1240
|
+
raise ValueError("Configuration file format is invalid: missing [database] section")
|
|
1241
|
+
|
|
1242
|
+
# Extract configuration values with PostgreSQL defaults
|
|
1243
|
+
try:
|
|
1244
|
+
name = config.get('database', 'name')
|
|
1245
|
+
user = config.get('database', 'user', fallback=os.environ.get('USER', ''))
|
|
1246
|
+
password = config.get('database', 'password', fallback='')
|
|
1247
|
+
host = config.get('database', 'host', fallback='')
|
|
1248
|
+
port_str = config.get('database', 'port', fallback='')
|
|
1249
|
+
production_str = config.get('database', 'production', fallback='False')
|
|
1250
|
+
docker_container = config.get('database', 'docker_container', fallback='')
|
|
1251
|
+
|
|
1252
|
+
# Convert port to int (default 5432 if empty)
|
|
1253
|
+
if port_str == '':
|
|
1254
|
+
port = 5432
|
|
1255
|
+
else:
|
|
1256
|
+
port = int(port_str)
|
|
1257
|
+
|
|
1258
|
+
# Convert production to bool
|
|
1259
|
+
production = config.getboolean('database', 'production', fallback=False)
|
|
1260
|
+
|
|
1261
|
+
return {
|
|
1262
|
+
'name': name,
|
|
1263
|
+
'user': user,
|
|
1264
|
+
'password': password,
|
|
1265
|
+
'host': host,
|
|
1266
|
+
'port': port,
|
|
1267
|
+
'production': production,
|
|
1268
|
+
'docker_container': docker_container
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
except (ValueError, TypeError) as e:
|
|
1272
|
+
raise ValueError(f"Configuration file format is invalid: {e}")
|
|
1273
|
+
|
|
1274
|
+
def _get_connection_params(self):
|
|
1275
|
+
"""
|
|
1276
|
+
Get current connection parameters for this database instance.
|
|
1277
|
+
|
|
1278
|
+
Returns the connection parameters dictionary for this Database instance,
|
|
1279
|
+
replacing direct access to DbConn properties. This method serves as the
|
|
1280
|
+
unified interface for accessing connection parameters during the migration
|
|
1281
|
+
from DbConn to integrated Database functionality.
|
|
1282
|
+
|
|
1283
|
+
Uses instance-level caching to avoid repeated file reads within the same
|
|
1284
|
+
Database instance lifecycle.
|
|
1285
|
+
|
|
1286
|
+
Returns:
|
|
1287
|
+
dict: Connection parameters dictionary with standardized keys:
|
|
1288
|
+
- name (str): Database name
|
|
1289
|
+
- user (str): Database user
|
|
1290
|
+
- password (str): Database password (empty string if not set)
|
|
1291
|
+
- host (str): Database host (empty string for Unix socket)
|
|
1292
|
+
- port (int): Database port (5432 default)
|
|
1293
|
+
- production (bool): Production environment flag
|
|
1294
|
+
Returns dict with defaults if no configuration exists or errors occur.
|
|
1295
|
+
|
|
1296
|
+
Examples:
|
|
1297
|
+
# Get connection parameters for existing database instance
|
|
1298
|
+
db = Database(repo)
|
|
1299
|
+
params = db._get_connection_params()
|
|
1300
|
+
# Returns: {'name': 'my_db', 'user': 'dev', 'password': '',
|
|
1301
|
+
# 'host': 'localhost', 'port': 5432, 'production': False}
|
|
1302
|
+
|
|
1303
|
+
# Access specific parameters (replaces DbConn.property access)
|
|
1304
|
+
user = db._get_connection_params()['user'] # replaces self.__connection_params.user
|
|
1305
|
+
host = db._get_connection_params()['host'] # replaces self.__connection_params.host
|
|
1306
|
+
prod = db._get_connection_params()['production'] # replaces self.__connection_params.production
|
|
1307
|
+
|
|
1308
|
+
Implementation Notes:
|
|
1309
|
+
- Uses _load_configuration() internally but handles all exceptions
|
|
1310
|
+
- Provides stable interface - never raises exceptions
|
|
1311
|
+
- Returns sensible defaults if configuration is missing/invalid
|
|
1312
|
+
- Serves as protective wrapper around _load_configuration()
|
|
1313
|
+
- Exceptions from _load_configuration() are caught and handled gracefully
|
|
1314
|
+
- Uses instance-level cache to avoid repeated file reads
|
|
1315
|
+
|
|
1316
|
+
Migration Notes:
|
|
1317
|
+
- Replaces self.__connection_params.user, .host, .port, .production access
|
|
1318
|
+
- Serves as transition method during DbConn elimination
|
|
1319
|
+
- Maintains compatibility with existing Database instance usage patterns
|
|
1320
|
+
- Will be used by state, production, and execute_pg_command properties
|
|
1321
|
+
"""
|
|
1322
|
+
# Return cached parameters if already loaded
|
|
1323
|
+
if hasattr(self, '_Database__connection_params_cache') and self.__connection_params_cache is not None:
|
|
1324
|
+
return self.__connection_params_cache
|
|
1325
|
+
|
|
1326
|
+
# Load configuration with defaults
|
|
1327
|
+
config = {
|
|
1328
|
+
'name': self.__repo.name,
|
|
1329
|
+
'user': os.environ.get('USER', ''),
|
|
1330
|
+
'password': '',
|
|
1331
|
+
'host': '',
|
|
1332
|
+
'port': 5432,
|
|
1333
|
+
'production': False
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
try:
|
|
1337
|
+
# Try to load configuration for this database
|
|
1338
|
+
loaded_config = self._load_configuration(self.__repo.name)
|
|
1339
|
+
if loaded_config is not None:
|
|
1340
|
+
config = loaded_config
|
|
1341
|
+
except (FileNotFoundError, PermissionError, ValueError):
|
|
1342
|
+
# Handle all possible exceptions from _load_configuration gracefully
|
|
1343
|
+
# Return sensible defaults to maintain stable interface
|
|
1344
|
+
pass
|
|
1345
|
+
|
|
1346
|
+
# Cache the result for subsequent calls
|
|
1347
|
+
self.__connection_params_cache = config
|
|
1348
|
+
return config
|
|
1349
|
+
|
|
1350
|
+
def get_postgres_version(self) -> tuple:
|
|
1351
|
+
"""
|
|
1352
|
+
Get PostgreSQL server version.
|
|
1353
|
+
|
|
1354
|
+
Returns:
|
|
1355
|
+
tuple: (major, minor) version numbers
|
|
1356
|
+
Examples: (13, 4), (16, 1), (17, 0)
|
|
1357
|
+
|
|
1358
|
+
Raises:
|
|
1359
|
+
DatabaseError: If version cannot be determined
|
|
1360
|
+
|
|
1361
|
+
Examples:
|
|
1362
|
+
version = db.get_postgres_version()
|
|
1363
|
+
if version >= (13, 0):
|
|
1364
|
+
# Use --force flag for dropdb
|
|
1365
|
+
pass
|
|
1366
|
+
"""
|
|
1367
|
+
try:
|
|
1368
|
+
result = self._execute_pg_command(
|
|
1369
|
+
self.__name,
|
|
1370
|
+
self._get_connection_params(),
|
|
1371
|
+
*['psql', '-d', 'postgres', '-t', '-A', '-c', 'SHOW server_version;'],
|
|
1372
|
+
)
|
|
1373
|
+
|
|
1374
|
+
# Output format: "16.1 (Ubuntu 16.1-1.pgdg22.04+1)"
|
|
1375
|
+
# Extract version: "16.1"
|
|
1376
|
+
version_str = result.stdout.strip().split()[0]
|
|
1377
|
+
|
|
1378
|
+
# Parse major.minor
|
|
1379
|
+
parts = version_str.split('.')
|
|
1380
|
+
major = int(parts[0])
|
|
1381
|
+
minor = int(parts[1]) if len(parts) > 1 else 0
|
|
1382
|
+
|
|
1383
|
+
return (major, minor)
|
|
1384
|
+
|
|
1385
|
+
except Exception as e:
|
|
1386
|
+
raise DatabaseError(
|
|
1387
|
+
f"Failed to get PostgreSQL version: {e}\n"
|
|
1388
|
+
f"Ensure PostgreSQL is installed and accessible."
|
|
1389
|
+
)
|