Skip to content

Commit 457b244

Browse files
author
Eugene Shershen
committed
implement UUID parameter binding
1 parent c85f84a commit 457b244

2 files changed

Lines changed: 315 additions & 40 deletions

File tree

.github/workflows/ci.yml

Lines changed: 38 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ on:
88
- main
99

1010
jobs:
11-
test:
12-
name: test
13-
runs-on: ${{ matrix.os }}
11+
test-linux:
12+
name: test-linux
13+
runs-on: ubuntu-latest
1414
services:
1515
postgres:
1616
image: postgres:15
@@ -27,35 +27,7 @@ jobs:
2727
- 5432:5432
2828
strategy:
2929
matrix:
30-
build: [linux_3.8, linux_3.9, linux_3.10, linux_3.11, linux_3.13, windows_3.9, windows_3.13, mac_3.9, mac_3.13]
31-
include:
32-
- build: linux_3.8
33-
os: ubuntu-latest
34-
python: "3.8"
35-
- build: linux_3.9
36-
os: ubuntu-latest
37-
python: "3.9"
38-
- build: linux_3.10
39-
os: ubuntu-latest
40-
python: "3.10"
41-
- build: linux_3.11
42-
os: ubuntu-latest
43-
python: "3.11"
44-
- build: linux_3.13
45-
os: ubuntu-latest
46-
python: "3.13.3"
47-
- build: windows_3.9
48-
os: windows-latest
49-
python: "3.9"
50-
- build: windows_3.13
51-
os: windows-latest
52-
python: "3.13.3"
53-
- build: mac_3.9
54-
os: macos-latest
55-
python: "3.9"
56-
- build: mac_3.13
57-
os: macos-latest
58-
python: "3.13.3"
30+
python: ["3.8", "3.9", "3.10", "3.11", "3.13.3"]
5931
steps:
6032
- name: Checkout repository
6133
uses: actions/checkout@v4
@@ -70,28 +42,54 @@ jobs:
7042
python -m pip install --upgrade pip wheel
7143
pip install -e ".[dev]"
7244
73-
- name: Run tests (with PostgreSQL on Linux)
74-
if: startsWith(matrix.build, 'linux')
45+
- name: Run tests with PostgreSQL
7546
run: python -m pytest tests/ -v
7647
env:
7748
DATABASE_URL: postgresql+psqlpy://postgres:password@localhost:5432/test_db
7849

79-
- name: Run tests (without PostgreSQL on Windows/Mac)
80-
if: "!startsWith(matrix.build, 'linux')"
81-
run: python -m pytest tests/ -v
82-
8350
- name: Produce coverage report
84-
if: matrix.build == 'linux_3.9'
51+
if: matrix.python == '3.9'
8552
run: pytest --cov=psqlpy_sqlalchemy --cov-report=xml
8653
env:
8754
DATABASE_URL: postgresql+psqlpy://postgres:password@localhost:5432/test_db
8855

8956
- name: Upload coverage report
90-
if: matrix.build == 'linux_3.9'
57+
if: matrix.python == '3.9'
9158
uses: codecov/codecov-action@v1
9259
with:
9360
file: ./coverage.xml
9461

62+
test-other:
63+
name: test-other
64+
runs-on: ${{ matrix.os }}
65+
strategy:
66+
matrix:
67+
include:
68+
- os: windows-latest
69+
python: "3.9"
70+
- os: windows-latest
71+
python: "3.13.3"
72+
- os: macos-latest
73+
python: "3.9"
74+
- os: macos-latest
75+
python: "3.13.3"
76+
steps:
77+
- name: Checkout repository
78+
uses: actions/checkout@v4
79+
80+
- name: Set up Python ${{ matrix.python }}
81+
uses: actions/setup-python@v5
82+
with:
83+
python-version: ${{ matrix.python }}
84+
85+
- name: Install dependencies
86+
run: |
87+
python -m pip install --upgrade pip wheel
88+
pip install -e ".[dev]"
89+
90+
- name: Run tests without PostgreSQL
91+
run: python -m pytest tests/ -v
92+
9593
# - name: Run pytest
9694
# run: python -m pytest tests/ -v
9795
#

tests/test_uuid_support.py

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
"""
2+
Tests for UUID parameter binding support in psqlpy-sqlalchemy.
3+
"""
4+
5+
import asyncio
6+
import uuid
7+
import pytest
8+
from sqlalchemy import Column, Integer, String, create_engine, text
9+
from sqlalchemy.dialects.postgresql import UUID
10+
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
11+
from sqlalchemy.orm import DeclarativeBase, sessionmaker
12+
from sqlalchemy.exc import StatementError
13+
14+
15+
import os
16+
17+
# Skip tests if database is not available (check for CI environment or explicit flag)
18+
def should_skip_db_tests():
19+
"""Check if database tests should be skipped."""
20+
# Run tests in CI environment
21+
if os.getenv('GITHUB_ACTIONS'):
22+
return False
23+
# Run tests if explicitly enabled
24+
if os.getenv('RUN_DB_TESTS'):
25+
return False
26+
# Skip by default in local development
27+
return True
28+
29+
pytestmark = pytest.mark.skipif(
30+
should_skip_db_tests(),
31+
reason="Database tests require live PostgreSQL connection. Set RUN_DB_TESTS=1 or run in CI."
32+
)
33+
34+
35+
class Base(DeclarativeBase):
36+
pass
37+
38+
39+
class TestUUIDTable(Base):
40+
__tablename__ = "test_uuid_table"
41+
42+
id = Column(Integer, primary_key=True)
43+
uid = Column(UUID(as_uuid=True), nullable=False)
44+
name = Column(String(100))
45+
46+
47+
@pytest.fixture
48+
async def engine():
49+
"""Create test engine."""
50+
# Use environment variables for database connection in CI
51+
db_url = os.getenv(
52+
'DATABASE_URL',
53+
'postgresql+psqlpy://postgres:password@localhost:5432/test_db'
54+
)
55+
engine = create_async_engine(db_url, echo=False)
56+
57+
async with engine.begin() as conn:
58+
await conn.run_sync(Base.metadata.create_all)
59+
60+
yield engine
61+
62+
async with engine.begin() as conn:
63+
await conn.run_sync(Base.metadata.drop_all)
64+
65+
await engine.dispose()
66+
67+
68+
@pytest.fixture
69+
async def session(engine):
70+
"""Create test session."""
71+
async_session = sessionmaker(engine, class_=AsyncSession)
72+
async with async_session() as session:
73+
yield session
74+
75+
76+
class TestUUIDParameterBinding:
77+
"""Test UUID parameter binding functionality."""
78+
79+
async def test_uuid_object_parameter(self, engine):
80+
"""Test UUID object as parameter."""
81+
test_uuid = uuid.uuid4()
82+
83+
async with engine.begin() as conn:
84+
# Insert with UUID object
85+
await conn.execute(
86+
text("INSERT INTO test_uuid_table (uid, name) VALUES (:uid, :name)"),
87+
{"uid": test_uuid, "name": "test_uuid_object"}
88+
)
89+
90+
# Query with UUID object
91+
result = await conn.execute(
92+
text("SELECT * FROM test_uuid_table WHERE uid = :uid"),
93+
{"uid": test_uuid}
94+
)
95+
96+
rows = result.fetchall()
97+
assert len(rows) == 1
98+
assert rows[0].name == "test_uuid_object"
99+
100+
async def test_uuid_string_parameter(self, engine):
101+
"""Test UUID string as parameter."""
102+
test_uuid = uuid.uuid4()
103+
test_uuid_str = str(test_uuid)
104+
105+
async with engine.begin() as conn:
106+
# Insert with UUID string
107+
await conn.execute(
108+
text("INSERT INTO test_uuid_table (uid, name) VALUES (:uid, :name)"),
109+
{"uid": test_uuid_str, "name": "test_uuid_string"}
110+
)
111+
112+
# Query with UUID string
113+
result = await conn.execute(
114+
text("SELECT * FROM test_uuid_table WHERE uid = :uid"),
115+
{"uid": test_uuid_str}
116+
)
117+
118+
rows = result.fetchall()
119+
assert len(rows) == 1
120+
assert rows[0].name == "test_uuid_string"
121+
122+
async def test_uuid_with_explicit_cast(self, engine):
123+
"""Test UUID parameter with explicit PostgreSQL casting."""
124+
test_uuid = uuid.uuid4()
125+
126+
async with engine.begin() as conn:
127+
# Insert test data
128+
await conn.execute(
129+
text("INSERT INTO test_uuid_table (uid, name) VALUES (:uid, :name)"),
130+
{"uid": test_uuid, "name": "test_cast"}
131+
)
132+
133+
# This was the original failing case - explicit UUID casting
134+
result = await conn.execute(
135+
text("SELECT * FROM test_uuid_table WHERE uid = :uid::UUID LIMIT :limit"),
136+
{"uid": str(test_uuid), "limit": 2}
137+
)
138+
139+
rows = result.fetchall()
140+
assert len(rows) == 1
141+
assert rows[0].name == "test_cast"
142+
143+
async def test_uuid_with_sqlalchemy_orm(self, session):
144+
"""Test UUID with SQLAlchemy ORM."""
145+
test_uuid = uuid.uuid4()
146+
147+
# Insert with ORM
148+
test_obj = TestUUIDTable(uid=test_uuid, name="test_orm")
149+
session.add(test_obj)
150+
await session.commit()
151+
152+
# Query with ORM
153+
result = await session.execute(
154+
text("SELECT * FROM test_uuid_table WHERE uid = :uid ORDER BY id LIMIT :limit"),
155+
{"uid": test_uuid, "limit": 2}
156+
)
157+
158+
rows = result.fetchall()
159+
assert len(rows) == 1
160+
assert rows[0].name == "test_orm"
161+
162+
async def test_multiple_uuid_parameters(self, engine):
163+
"""Test multiple UUID parameters in one query."""
164+
uuid1 = uuid.uuid4()
165+
uuid2 = uuid.uuid4()
166+
167+
async with engine.begin() as conn:
168+
# Insert test data
169+
await conn.execute(
170+
text("INSERT INTO test_uuid_table (uid, name) VALUES (:uid1, :name1), (:uid2, :name2)"),
171+
{"uid1": uuid1, "name1": "first", "uid2": uuid2, "name2": "second"}
172+
)
173+
174+
# Query with multiple UUID parameters
175+
result = await conn.execute(
176+
text("SELECT * FROM test_uuid_table WHERE uid IN (:uid1, :uid2) ORDER BY name"),
177+
{"uid1": uuid1, "uid2": uuid2}
178+
)
179+
180+
rows = result.fetchall()
181+
assert len(rows) == 2
182+
assert rows[0].name == "first"
183+
assert rows[1].name == "second"
184+
185+
async def test_null_uuid_parameter(self, engine):
186+
"""Test NULL UUID parameter."""
187+
async with engine.begin() as conn:
188+
# Query with NULL UUID - should return no results
189+
result = await conn.execute(
190+
text("SELECT * FROM test_uuid_table WHERE uid = :uid"),
191+
{"uid": None}
192+
)
193+
194+
rows = result.fetchall()
195+
assert len(rows) == 0
196+
197+
async def test_invalid_uuid_string(self, engine):
198+
"""Test invalid UUID string raises proper error."""
199+
async with engine.begin() as conn:
200+
with pytest.raises((ValueError, StatementError)):
201+
await conn.execute(
202+
text("INSERT INTO test_uuid_table (uid, name) VALUES (:uid, :name)"),
203+
{"uid": "invalid-uuid-string", "name": "test"}
204+
)
205+
206+
async def test_uuid_edge_cases(self, engine):
207+
"""Test UUID edge cases."""
208+
# Test various UUID formats
209+
test_cases = [
210+
uuid.UUID('00000000-0000-0000-0000-000000000000'), # Nil UUID
211+
uuid.UUID('ffffffff-ffff-ffff-ffff-ffffffffffff'), # Max UUID
212+
uuid.uuid1(), # Time-based UUID
213+
uuid.uuid4(), # Random UUID
214+
]
215+
216+
async with engine.begin() as conn:
217+
for i, test_uuid in enumerate(test_cases):
218+
await conn.execute(
219+
text("INSERT INTO test_uuid_table (uid, name) VALUES (:uid, :name)"),
220+
{"uid": test_uuid, "name": f"edge_case_{i}"}
221+
)
222+
223+
# Verify all were inserted correctly
224+
result = await conn.execute(
225+
text("SELECT COUNT(*) as count FROM test_uuid_table WHERE name LIKE 'edge_case_%'")
226+
)
227+
228+
count = result.fetchone().count
229+
assert count == len(test_cases)
230+
231+
232+
class TestUUIDTypeCompatibility:
233+
"""Test UUID type compatibility with existing functionality."""
234+
235+
async def test_uuid_column_definition(self, engine):
236+
"""Test that UUID columns are properly defined."""
237+
async with engine.begin() as conn:
238+
# Check table structure
239+
result = await conn.execute(
240+
text("""
241+
SELECT column_name, data_type
242+
FROM information_schema.columns
243+
WHERE table_name = 'test_uuid_table' AND column_name = 'uid'
244+
""")
245+
)
246+
247+
row = result.fetchone()
248+
assert row is not None
249+
assert row.data_type == 'uuid'
250+
251+
async def test_uuid_index_support(self, engine):
252+
"""Test that UUID columns can be indexed."""
253+
async with engine.begin() as conn:
254+
# Create index on UUID column
255+
await conn.execute(
256+
text("CREATE INDEX IF NOT EXISTS idx_test_uuid_uid ON test_uuid_table(uid)")
257+
)
258+
259+
# Verify index was created
260+
result = await conn.execute(
261+
text("""
262+
SELECT indexname
263+
FROM pg_indexes
264+
WHERE tablename = 'test_uuid_table' AND indexname = 'idx_test_uuid_uid'
265+
""")
266+
)
267+
268+
row = result.fetchone()
269+
assert row is not None
270+
271+
# Clean up
272+
await conn.execute(text("DROP INDEX IF EXISTS idx_test_uuid_uid"))
273+
274+
275+
if __name__ == "__main__":
276+
# Run tests directly
277+
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)