iceaxe 0.7.0.dev2__tar.gz → 0.7.1__tar.gz
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.
Potentially problematic release.
This version of iceaxe might be problematic. Click here for more details.
- {iceaxe-0.7.0.dev2/iceaxe.egg-info → iceaxe-0.7.1}/PKG-INFO +1 -1
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/conftest.py +97 -28
- iceaxe-0.7.1/iceaxe/__tests__/docker_helpers.py +208 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/migrations/test_action_sorter.py +1 -1
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/schemas/test_actions.py +5 -5
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/schemas/test_db_memory_serializer.py +2 -2
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/schemas/test_db_serializer.py +46 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/test_comparison.py +27 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/test_session.py +32 -30
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/migrations/migration.py +22 -2
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/migrations/migrator.py +13 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/schemas/actions.py +2 -2
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/schemas/db_memory_serializer.py +4 -4
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/schemas/db_stubs.py +22 -16
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/session_optimized.c +244 -68
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/sql_types.py +33 -4
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1/iceaxe.egg-info}/PKG-INFO +1 -1
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe.egg-info/SOURCES.txt +1 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/pyproject.toml +2 -2
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/LICENSE +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/MANIFEST.in +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/README.md +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__init__.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/__init__.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/benchmarks/__init__.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/benchmarks/test_bulk_insert.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/benchmarks/test_select.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/conf_models.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/helpers.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/migrations/__init__.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/migrations/conftest.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/migrations/test_generator.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/migrations/test_generics.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/mountaineer/__init__.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/mountaineer/dependencies/__init__.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/mountaineer/dependencies/test_core.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/schemas/__init__.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/schemas/test_cli.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/schemas/test_db_stubs.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/test_alias.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/test_base.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/test_field.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/test_helpers.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/test_modifications.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/test_queries.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/test_queries_str.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/__tests__/test_text_search.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/alias_values.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/base.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/comparison.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/field.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/functions.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/generics.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/io.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/logging.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/migrations/__init__.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/migrations/action_sorter.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/migrations/cli.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/migrations/client_io.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/migrations/generator.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/modifications.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/mountaineer/__init__.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/mountaineer/cli.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/mountaineer/config.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/mountaineer/dependencies/__init__.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/mountaineer/dependencies/core.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/postgres.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/py.typed +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/queries.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/queries_str.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/schemas/__init__.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/schemas/cli.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/schemas/db_serializer.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/session.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/session_optimized.pyx +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe/typing.py +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe.egg-info/dependency_links.txt +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe.egg-info/requires.txt +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/iceaxe.egg-info/top_level.txt +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/setup.cfg +0 -0
- {iceaxe-0.7.0.dev2 → iceaxe-0.7.1}/setup.py +0 -0
|
@@ -1,84 +1,151 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
1
3
|
import asyncpg
|
|
2
4
|
import pytest
|
|
3
5
|
import pytest_asyncio
|
|
4
6
|
|
|
7
|
+
from iceaxe.__tests__ import docker_helpers
|
|
5
8
|
from iceaxe.base import DBModelMetaclass
|
|
6
9
|
from iceaxe.session import DBConnection
|
|
7
10
|
|
|
11
|
+
# Configure logging
|
|
12
|
+
logging.basicConfig(level=logging.INFO)
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture(scope="session")
|
|
17
|
+
def docker_postgres():
|
|
18
|
+
"""
|
|
19
|
+
Fixture that creates a PostgreSQL container using the Python Docker API.
|
|
20
|
+
This allows running individual tests without needing Docker Compose.
|
|
21
|
+
"""
|
|
22
|
+
# Create and start a PostgreSQL container
|
|
23
|
+
postgres_container = docker_helpers.PostgresContainer()
|
|
24
|
+
|
|
25
|
+
# Start the container and yield connection details
|
|
26
|
+
connection_info = postgres_container.start()
|
|
27
|
+
yield connection_info
|
|
28
|
+
|
|
29
|
+
# Cleanup: stop the container
|
|
30
|
+
postgres_container.stop()
|
|
31
|
+
|
|
8
32
|
|
|
9
33
|
@pytest_asyncio.fixture
|
|
10
|
-
async def db_connection():
|
|
34
|
+
async def db_connection(docker_postgres):
|
|
35
|
+
"""
|
|
36
|
+
Create a database connection using the PostgreSQL container.
|
|
37
|
+
"""
|
|
11
38
|
conn = DBConnection(
|
|
12
39
|
await asyncpg.connect(
|
|
13
|
-
host="
|
|
14
|
-
port=
|
|
15
|
-
user="
|
|
16
|
-
password="
|
|
17
|
-
database="
|
|
40
|
+
host=docker_postgres["host"],
|
|
41
|
+
port=docker_postgres["port"],
|
|
42
|
+
user=docker_postgres["user"],
|
|
43
|
+
password=docker_postgres["password"],
|
|
44
|
+
database=docker_postgres["database"],
|
|
18
45
|
)
|
|
19
46
|
)
|
|
20
47
|
|
|
21
48
|
# Drop all tables first to ensure clean state
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
49
|
+
known_tables = [
|
|
50
|
+
"artifactdemo",
|
|
51
|
+
"userdemo",
|
|
52
|
+
"complexdemo",
|
|
53
|
+
"article",
|
|
54
|
+
"employee",
|
|
55
|
+
"department",
|
|
56
|
+
"projectassignment",
|
|
57
|
+
"employeemetadata",
|
|
58
|
+
"functiondemomodel",
|
|
59
|
+
"demomodela",
|
|
60
|
+
"demomodelb",
|
|
61
|
+
"jsondemo",
|
|
62
|
+
"complextypedemo",
|
|
63
|
+
]
|
|
64
|
+
known_types = ["statusenum", "employeestatus"]
|
|
65
|
+
|
|
66
|
+
for table in known_tables:
|
|
67
|
+
await conn.conn.execute(f"DROP TABLE IF EXISTS {table} CASCADE", timeout=30.0)
|
|
68
|
+
|
|
69
|
+
for known_type in known_types:
|
|
70
|
+
await conn.conn.execute(
|
|
71
|
+
f"DROP TYPE IF EXISTS {known_type} CASCADE", timeout=30.0
|
|
72
|
+
)
|
|
26
73
|
|
|
27
74
|
# Create tables
|
|
28
|
-
await conn.conn.execute(
|
|
75
|
+
await conn.conn.execute(
|
|
76
|
+
"""
|
|
29
77
|
CREATE TABLE IF NOT EXISTS userdemo (
|
|
30
78
|
id SERIAL PRIMARY KEY,
|
|
31
79
|
name TEXT,
|
|
32
80
|
email TEXT
|
|
33
81
|
)
|
|
34
|
-
"""
|
|
82
|
+
""",
|
|
83
|
+
timeout=30.0,
|
|
84
|
+
)
|
|
35
85
|
|
|
36
|
-
await conn.conn.execute(
|
|
86
|
+
await conn.conn.execute(
|
|
87
|
+
"""
|
|
37
88
|
CREATE TABLE IF NOT EXISTS artifactdemo (
|
|
38
89
|
id SERIAL PRIMARY KEY,
|
|
39
90
|
title TEXT,
|
|
40
91
|
user_id INT REFERENCES userdemo(id)
|
|
41
92
|
)
|
|
42
|
-
"""
|
|
93
|
+
""",
|
|
94
|
+
timeout=30.0,
|
|
95
|
+
)
|
|
43
96
|
|
|
44
|
-
await conn.conn.execute(
|
|
97
|
+
await conn.conn.execute(
|
|
98
|
+
"""
|
|
45
99
|
CREATE TABLE IF NOT EXISTS complexdemo (
|
|
46
100
|
id SERIAL PRIMARY KEY,
|
|
47
101
|
string_list TEXT[],
|
|
48
102
|
json_data JSON
|
|
49
103
|
)
|
|
50
|
-
"""
|
|
104
|
+
""",
|
|
105
|
+
timeout=30.0,
|
|
106
|
+
)
|
|
51
107
|
|
|
52
|
-
await conn.conn.execute(
|
|
108
|
+
await conn.conn.execute(
|
|
109
|
+
"""
|
|
53
110
|
CREATE TABLE IF NOT EXISTS article (
|
|
54
111
|
id SERIAL PRIMARY KEY,
|
|
55
112
|
title TEXT,
|
|
56
113
|
content TEXT,
|
|
57
114
|
summary TEXT
|
|
58
115
|
)
|
|
59
|
-
"""
|
|
116
|
+
""",
|
|
117
|
+
timeout=30.0,
|
|
118
|
+
)
|
|
60
119
|
|
|
61
120
|
# Create each index separately to handle errors better
|
|
62
121
|
yield conn
|
|
63
122
|
|
|
64
123
|
# Drop all tables after tests
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
124
|
+
for table in known_tables:
|
|
125
|
+
await conn.conn.execute(f"DROP TABLE IF EXISTS {table} CASCADE", timeout=30.0)
|
|
126
|
+
|
|
127
|
+
# Drop all types after tests
|
|
128
|
+
for known_type in known_types:
|
|
129
|
+
await conn.conn.execute(
|
|
130
|
+
f"DROP TYPE IF EXISTS {known_type} CASCADE", timeout=30.0
|
|
131
|
+
)
|
|
132
|
+
|
|
69
133
|
await conn.conn.close()
|
|
70
134
|
|
|
71
135
|
|
|
72
136
|
@pytest_asyncio.fixture()
|
|
73
137
|
async def indexed_db_connection(db_connection: DBConnection):
|
|
74
138
|
await db_connection.conn.execute(
|
|
75
|
-
"CREATE INDEX IF NOT EXISTS article_title_tsv_idx ON article USING GIN (to_tsvector('english', title))"
|
|
139
|
+
"CREATE INDEX IF NOT EXISTS article_title_tsv_idx ON article USING GIN (to_tsvector('english', title))",
|
|
140
|
+
timeout=30.0,
|
|
76
141
|
)
|
|
77
142
|
await db_connection.conn.execute(
|
|
78
|
-
"CREATE INDEX IF NOT EXISTS article_content_tsv_idx ON article USING GIN (to_tsvector('english', content))"
|
|
143
|
+
"CREATE INDEX IF NOT EXISTS article_content_tsv_idx ON article USING GIN (to_tsvector('english', content))",
|
|
144
|
+
timeout=30.0,
|
|
79
145
|
)
|
|
80
146
|
await db_connection.conn.execute(
|
|
81
|
-
"CREATE INDEX IF NOT EXISTS article_summary_tsv_idx ON article USING GIN (to_tsvector('english', summary))"
|
|
147
|
+
"CREATE INDEX IF NOT EXISTS article_summary_tsv_idx ON article USING GIN (to_tsvector('english', summary))",
|
|
148
|
+
timeout=30.0,
|
|
82
149
|
)
|
|
83
150
|
|
|
84
151
|
yield db_connection
|
|
@@ -88,7 +155,7 @@ async def indexed_db_connection(db_connection: DBConnection):
|
|
|
88
155
|
async def clear_table(db_connection):
|
|
89
156
|
# Clear all tables and reset sequences
|
|
90
157
|
await db_connection.conn.execute(
|
|
91
|
-
"TRUNCATE TABLE userdemo, article RESTART IDENTITY CASCADE"
|
|
158
|
+
"TRUNCATE TABLE userdemo, article RESTART IDENTITY CASCADE", timeout=30.0
|
|
92
159
|
)
|
|
93
160
|
|
|
94
161
|
|
|
@@ -107,7 +174,8 @@ async def clear_all_database_objects(db_connection: DBConnection):
|
|
|
107
174
|
EXECUTE 'DROP TABLE IF EXISTS ' || quote_ident(r.tablename) || ' CASCADE';
|
|
108
175
|
END LOOP;
|
|
109
176
|
END $$;
|
|
110
|
-
"""
|
|
177
|
+
""",
|
|
178
|
+
timeout=30.0,
|
|
111
179
|
)
|
|
112
180
|
|
|
113
181
|
# Step 2: Drop all custom types in the public schema
|
|
@@ -120,7 +188,8 @@ async def clear_all_database_objects(db_connection: DBConnection):
|
|
|
120
188
|
EXECUTE 'DROP TYPE IF EXISTS ' || quote_ident(r.typname) || ' CASCADE';
|
|
121
189
|
END LOOP;
|
|
122
190
|
END $$;
|
|
123
|
-
"""
|
|
191
|
+
""",
|
|
192
|
+
timeout=30.0,
|
|
124
193
|
)
|
|
125
194
|
|
|
126
195
|
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Docker helper utilities for testing.
|
|
3
|
+
|
|
4
|
+
This module provides classes and functions to manage Docker containers for testing,
|
|
5
|
+
particularly focusing on PostgreSQL database containers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import socket
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from typing import Any, Dict, Optional, cast
|
|
13
|
+
|
|
14
|
+
import docker
|
|
15
|
+
from docker.errors import APIError
|
|
16
|
+
|
|
17
|
+
# Configure logging
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_free_port() -> int:
|
|
22
|
+
"""Find a free port on the host machine."""
|
|
23
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
24
|
+
s.bind(("", 0))
|
|
25
|
+
return s.getsockname()[1]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PostgresContainer:
|
|
29
|
+
"""
|
|
30
|
+
A class that manages a PostgreSQL Docker container for testing.
|
|
31
|
+
|
|
32
|
+
This class handles the lifecycle of a PostgreSQL container, including:
|
|
33
|
+
- Starting the container with appropriate configuration
|
|
34
|
+
- Finding available ports
|
|
35
|
+
- Waiting for the container to be ready
|
|
36
|
+
- Providing connection information
|
|
37
|
+
- Cleaning up after tests
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
pg_user: str = "iceaxe",
|
|
43
|
+
pg_password: str = "mysecretpassword",
|
|
44
|
+
pg_db: str = "iceaxe_test_db",
|
|
45
|
+
postgres_version: str = "16",
|
|
46
|
+
):
|
|
47
|
+
self.pg_user = pg_user
|
|
48
|
+
self.pg_password = pg_password
|
|
49
|
+
self.pg_db = pg_db
|
|
50
|
+
self.postgres_version = postgres_version
|
|
51
|
+
self.port = get_free_port()
|
|
52
|
+
self.container: Optional[Any] = None
|
|
53
|
+
self.client = docker.from_env()
|
|
54
|
+
self.container_name = f"iceaxe-postgres-test-{uuid.uuid4().hex[:8]}"
|
|
55
|
+
|
|
56
|
+
def start(self) -> Dict[str, Any]:
|
|
57
|
+
"""
|
|
58
|
+
Start the PostgreSQL container.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Dict[str, Any]: Connection information for the PostgreSQL container
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
RuntimeError: If the container fails to start or become ready
|
|
65
|
+
"""
|
|
66
|
+
logger.info(f"Starting PostgreSQL container on port {self.port}")
|
|
67
|
+
|
|
68
|
+
max_attempts = 3
|
|
69
|
+
attempt = 0
|
|
70
|
+
|
|
71
|
+
while attempt < max_attempts:
|
|
72
|
+
attempt += 1
|
|
73
|
+
try:
|
|
74
|
+
self.container = self._run_container(self.port)
|
|
75
|
+
break
|
|
76
|
+
except APIError as e:
|
|
77
|
+
if "port is already allocated" in str(e) and attempt < max_attempts:
|
|
78
|
+
logger.warning(
|
|
79
|
+
f"Port {self.port} is still in use. Trying with a new port (attempt {attempt}/{max_attempts})."
|
|
80
|
+
)
|
|
81
|
+
self.port = get_free_port()
|
|
82
|
+
else:
|
|
83
|
+
raise RuntimeError(f"Failed to start PostgreSQL container: {e}")
|
|
84
|
+
|
|
85
|
+
# Wait for PostgreSQL to be ready
|
|
86
|
+
if not self._wait_for_container_ready():
|
|
87
|
+
self.stop()
|
|
88
|
+
raise RuntimeError("Failed to connect to PostgreSQL container")
|
|
89
|
+
|
|
90
|
+
return self.get_connection_info()
|
|
91
|
+
|
|
92
|
+
def _run_container(
|
|
93
|
+
self, port: int
|
|
94
|
+
) -> Any: # Type as Any since docker.models.containers.Container isn't imported
|
|
95
|
+
"""
|
|
96
|
+
Run the Docker container with the specified port.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
port: The port to map PostgreSQL to on the host
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
The Docker container object
|
|
103
|
+
"""
|
|
104
|
+
return self.client.containers.run(
|
|
105
|
+
f"postgres:{self.postgres_version}",
|
|
106
|
+
name=self.container_name,
|
|
107
|
+
detach=True,
|
|
108
|
+
environment={
|
|
109
|
+
"POSTGRES_USER": self.pg_user,
|
|
110
|
+
"POSTGRES_PASSWORD": self.pg_password,
|
|
111
|
+
"POSTGRES_DB": self.pg_db,
|
|
112
|
+
# Additional settings for faster startup in testing
|
|
113
|
+
"POSTGRES_HOST_AUTH_METHOD": "trust",
|
|
114
|
+
},
|
|
115
|
+
ports={"5432/tcp": port},
|
|
116
|
+
remove=True, # Auto-remove container when stopped
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def _wait_for_container_ready(self) -> bool:
|
|
120
|
+
"""
|
|
121
|
+
Wait for the PostgreSQL container to be ready.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
bool: True if the container is ready, False otherwise
|
|
125
|
+
"""
|
|
126
|
+
max_retries = 30
|
|
127
|
+
retry_interval = 1
|
|
128
|
+
|
|
129
|
+
for i in range(max_retries):
|
|
130
|
+
try:
|
|
131
|
+
if self.container is None:
|
|
132
|
+
logger.warning("Container is None, cannot proceed")
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
# We've already checked that self.container is not None
|
|
136
|
+
container = cast(Any, self.container)
|
|
137
|
+
container.reload() # Refresh container status
|
|
138
|
+
if container.status != "running":
|
|
139
|
+
logger.warning(f"Container status: {container.status}")
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
# Try to connect to PostgreSQL
|
|
143
|
+
conn = socket.create_connection(("localhost", self.port), timeout=1)
|
|
144
|
+
conn.close()
|
|
145
|
+
# Wait a bit more to ensure PostgreSQL is fully initialized
|
|
146
|
+
time.sleep(2)
|
|
147
|
+
logger.info(f"PostgreSQL container is ready after {i + 1} attempt(s)")
|
|
148
|
+
return True
|
|
149
|
+
except (socket.error, ConnectionRefusedError) as e:
|
|
150
|
+
if i == max_retries - 1:
|
|
151
|
+
logger.warning(
|
|
152
|
+
f"Failed to connect after {max_retries} attempts: {e}"
|
|
153
|
+
)
|
|
154
|
+
return False
|
|
155
|
+
time.sleep(retry_interval)
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logger.warning(f"Unexpected error checking container readiness: {e}")
|
|
158
|
+
if i == max_retries - 1:
|
|
159
|
+
return False
|
|
160
|
+
time.sleep(retry_interval)
|
|
161
|
+
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
def stop(self) -> None:
|
|
165
|
+
"""
|
|
166
|
+
Stop the PostgreSQL container.
|
|
167
|
+
|
|
168
|
+
This method ensures the container is properly stopped and removed.
|
|
169
|
+
"""
|
|
170
|
+
if self.container is not None:
|
|
171
|
+
try:
|
|
172
|
+
logger.info(f"Stopping PostgreSQL container {self.container_name}")
|
|
173
|
+
# We've already checked that self.container is not None
|
|
174
|
+
container = cast(Any, self.container)
|
|
175
|
+
container.stop(timeout=10) # Allow 10 seconds for graceful shutdown
|
|
176
|
+
except Exception as e:
|
|
177
|
+
logger.warning(f"Failed to stop container: {e}")
|
|
178
|
+
try:
|
|
179
|
+
# Force remove as a fallback
|
|
180
|
+
if self.container is not None:
|
|
181
|
+
self.container.remove(force=True)
|
|
182
|
+
logger.info("Forced container removal")
|
|
183
|
+
except Exception as e2:
|
|
184
|
+
logger.warning(f"Failed to force remove container: {e2}")
|
|
185
|
+
|
|
186
|
+
def get_connection_info(self) -> Dict[str, Any]:
|
|
187
|
+
"""
|
|
188
|
+
Get the connection information for the PostgreSQL container.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Dict[str, Any]: A dictionary containing connection parameters
|
|
192
|
+
"""
|
|
193
|
+
return {
|
|
194
|
+
"host": "localhost",
|
|
195
|
+
"port": self.port,
|
|
196
|
+
"user": self.pg_user,
|
|
197
|
+
"password": self.pg_password,
|
|
198
|
+
"database": self.pg_db,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
def get_connection_string(self) -> str:
|
|
202
|
+
"""
|
|
203
|
+
Get a PostgreSQL connection string.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
str: A connection string in the format 'postgresql://user:password@host:port/database'
|
|
207
|
+
"""
|
|
208
|
+
return f"postgresql://{self.pg_user}:{self.pg_password}@localhost:{self.port}/{self.pg_db}"
|
|
@@ -19,7 +19,7 @@ class MockNode(DBObject):
|
|
|
19
19
|
async def create(self, actor: DatabaseActions):
|
|
20
20
|
pass
|
|
21
21
|
|
|
22
|
-
async def migrate(self, previous:
|
|
22
|
+
async def migrate(self, previous: DBObject, actor: DatabaseActions):
|
|
23
23
|
pass
|
|
24
24
|
|
|
25
25
|
async def destroy(self, actor: DatabaseActions):
|
|
@@ -263,8 +263,8 @@ async def test_add_column_any_type(
|
|
|
263
263
|
(ColumnType.SERIAL, ColumnType.INTEGER),
|
|
264
264
|
(ColumnType.BIGSERIAL, ColumnType.BIGINT),
|
|
265
265
|
(ColumnType.CHAR, "character"),
|
|
266
|
-
(ColumnType.
|
|
267
|
-
(ColumnType.
|
|
266
|
+
(ColumnType.TIME_WITHOUT_TIME_ZONE, "time without time zone"),
|
|
267
|
+
(ColumnType.TIMESTAMP_WITHOUT_TIME_ZONE, "timestamp without time zone"),
|
|
268
268
|
)
|
|
269
269
|
|
|
270
270
|
allowed_values = {enum_value.value}
|
|
@@ -365,7 +365,7 @@ async def test_modify_column_type(
|
|
|
365
365
|
(ColumnType.VARCHAR, ColumnType.DATE, "2023-01-01", "2023-01-01", True),
|
|
366
366
|
(
|
|
367
367
|
ColumnType.TEXT,
|
|
368
|
-
ColumnType.
|
|
368
|
+
ColumnType.TIMESTAMP_WITHOUT_TIME_ZONE,
|
|
369
369
|
"2023-01-01 12:00:00",
|
|
370
370
|
"2023-01-01 12:00:00",
|
|
371
371
|
True,
|
|
@@ -443,7 +443,7 @@ async def test_modify_column_type_with_autocast(
|
|
|
443
443
|
actual_value = row[column_name]
|
|
444
444
|
if isinstance(expected_value, str) and to_type in [
|
|
445
445
|
ColumnType.DATE,
|
|
446
|
-
ColumnType.
|
|
446
|
+
ColumnType.TIMESTAMP_WITHOUT_TIME_ZONE,
|
|
447
447
|
]:
|
|
448
448
|
# For date/timestamp, convert to string for comparison
|
|
449
449
|
actual_value = str(actual_value)
|
|
@@ -1158,7 +1158,7 @@ async def test_modify_column_type_date_to_timestamp(
|
|
|
1158
1158
|
await db_backed_actions.modify_column_type(
|
|
1159
1159
|
table_name,
|
|
1160
1160
|
column_name,
|
|
1161
|
-
explicit_data_type=ColumnType.
|
|
1161
|
+
explicit_data_type=ColumnType.TIMESTAMP_WITHOUT_TIME_ZONE,
|
|
1162
1162
|
autocast=False,
|
|
1163
1163
|
)
|
|
1164
1164
|
|
|
@@ -750,7 +750,7 @@ def test_enum_column_assignment(clear_all_database_objects):
|
|
|
750
750
|
DBColumn(
|
|
751
751
|
table_name="exampledbmodel",
|
|
752
752
|
column_name="standard_datetime",
|
|
753
|
-
column_type=ColumnType.
|
|
753
|
+
column_type=ColumnType.TIMESTAMP_WITHOUT_TIME_ZONE,
|
|
754
754
|
column_is_list=False,
|
|
755
755
|
nullable=False,
|
|
756
756
|
),
|
|
@@ -810,7 +810,7 @@ def test_enum_column_assignment(clear_all_database_objects):
|
|
|
810
810
|
DBColumn(
|
|
811
811
|
table_name="exampledbmodel",
|
|
812
812
|
column_name="standard_time",
|
|
813
|
-
column_type=ColumnType.
|
|
813
|
+
column_type=ColumnType.TIME_WITHOUT_TIME_ZONE,
|
|
814
814
|
column_is_list=False,
|
|
815
815
|
nullable=False,
|
|
816
816
|
),
|
|
@@ -143,6 +143,52 @@ class ValueEnumInt(IntEnum):
|
|
|
143
143
|
)
|
|
144
144
|
],
|
|
145
145
|
),
|
|
146
|
+
# Test PostgreSQL's storage format for timestamp without timezone
|
|
147
|
+
(
|
|
148
|
+
"""
|
|
149
|
+
CREATE TABLE exampledbmodel (
|
|
150
|
+
id SERIAL PRIMARY KEY,
|
|
151
|
+
created_at TIMESTAMP NOT NULL
|
|
152
|
+
);
|
|
153
|
+
""",
|
|
154
|
+
[
|
|
155
|
+
(
|
|
156
|
+
DBColumn(
|
|
157
|
+
table_name="exampledbmodel",
|
|
158
|
+
column_name="created_at",
|
|
159
|
+
column_type=ColumnType.TIMESTAMP_WITHOUT_TIME_ZONE,
|
|
160
|
+
column_is_list=False,
|
|
161
|
+
nullable=False,
|
|
162
|
+
),
|
|
163
|
+
[
|
|
164
|
+
DBTable(table_name="exampledbmodel"),
|
|
165
|
+
],
|
|
166
|
+
)
|
|
167
|
+
],
|
|
168
|
+
),
|
|
169
|
+
# Test PostgreSQL's storage format for timestamp with timezone
|
|
170
|
+
(
|
|
171
|
+
"""
|
|
172
|
+
CREATE TABLE exampledbmodel (
|
|
173
|
+
id SERIAL PRIMARY KEY,
|
|
174
|
+
created_at TIMESTAMPTZ NOT NULL
|
|
175
|
+
);
|
|
176
|
+
""",
|
|
177
|
+
[
|
|
178
|
+
(
|
|
179
|
+
DBColumn(
|
|
180
|
+
table_name="exampledbmodel",
|
|
181
|
+
column_name="created_at",
|
|
182
|
+
column_type=ColumnType.TIMESTAMP_WITH_TIME_ZONE,
|
|
183
|
+
column_is_list=False,
|
|
184
|
+
nullable=False,
|
|
185
|
+
),
|
|
186
|
+
[
|
|
187
|
+
DBTable(table_name="exampledbmodel"),
|
|
188
|
+
],
|
|
189
|
+
)
|
|
190
|
+
],
|
|
191
|
+
),
|
|
146
192
|
],
|
|
147
193
|
)
|
|
148
194
|
async def test_simple_db_serializer(
|
|
@@ -10,6 +10,7 @@ from iceaxe.base import TableBase
|
|
|
10
10
|
from iceaxe.comparison import ComparisonType, FieldComparison
|
|
11
11
|
from iceaxe.field import DBFieldClassDefinition, DBFieldInfo
|
|
12
12
|
from iceaxe.queries_str import QueryLiteral
|
|
13
|
+
from iceaxe.sql_types import ColumnType
|
|
13
14
|
from iceaxe.typing import column
|
|
14
15
|
|
|
15
16
|
|
|
@@ -354,3 +355,29 @@ def test_force_join_constraints(
|
|
|
354
355
|
)
|
|
355
356
|
forced = comparison.force_join_constraints()
|
|
356
357
|
assert forced.comparison == expected_comparison
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@pytest.mark.parametrize(
|
|
361
|
+
"sql_type_string, expected_column_type",
|
|
362
|
+
[
|
|
363
|
+
("timestamp", ColumnType.TIMESTAMP_WITHOUT_TIME_ZONE), # Tests aliasing
|
|
364
|
+
("timestamp without time zone", ColumnType.TIMESTAMP_WITHOUT_TIME_ZONE),
|
|
365
|
+
("timestamp with time zone", ColumnType.TIMESTAMP_WITH_TIME_ZONE),
|
|
366
|
+
("time", ColumnType.TIME_WITHOUT_TIME_ZONE), # Tests aliasing
|
|
367
|
+
("time without time zone", ColumnType.TIME_WITHOUT_TIME_ZONE),
|
|
368
|
+
("time with time zone", ColumnType.TIME_WITH_TIME_ZONE),
|
|
369
|
+
],
|
|
370
|
+
)
|
|
371
|
+
def test_postgres_datetime_timezone_casting(
|
|
372
|
+
sql_type_string: str, expected_column_type: ColumnType
|
|
373
|
+
):
|
|
374
|
+
"""
|
|
375
|
+
Test that PostgresDateTime fields with different timezone configurations
|
|
376
|
+
are properly handled by the ColumnType enum, specifically testing that
|
|
377
|
+
PostgreSQL's storage format ('timestamp without time zone') can be parsed.
|
|
378
|
+
This also tests that SQL standard aliases like "timestamp" correctly map
|
|
379
|
+
to "timestamp without time zone".
|
|
380
|
+
"""
|
|
381
|
+
|
|
382
|
+
# Test that ColumnType enum can handle PostgreSQL's storage formats and aliases
|
|
383
|
+
assert ColumnType(sql_type_string) == expected_column_type
|