Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 101 additions & 32 deletions .github/workflows/smoke-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ on:
push:
branches: [main]
schedule:
- cron: "0 0 * * 0"
- cron: "0 0 * * *"
repository_dispatch:
types: [upstream-released]
workflow_dispatch:

concurrency:
group: smoke-tests-${{ github.workflow }}-${{ github.ref }}
Expand All @@ -14,20 +17,22 @@ concurrency:
jobs:
verify:
runs-on: ubuntu-latest
services:
cubrid:
image: cubrid/cubrid:11.2
env:
CUBRID_DB: testdb
ports:
- 33000:33000
options: >-
--health-cmd "csql -u dba testdb -c 'SELECT 1'"
--health-interval 15s
--health-timeout 10s
--health-retries 10
--health-start-period 30s
steps:
- name: Report trigger
env:
# Pass untrusted repository_dispatch payload through the environment
# instead of interpolating it directly into the shell, which would be
# a command-injection vector.
EVENT_NAME: ${{ github.event_name }}
UPSTREAM_PACKAGE: ${{ github.event.client_payload.package }}
UPSTREAM_REF: ${{ github.event.client_payload.ref }}
run: |
printf 'Event: %s\n' "$EVENT_NAME"
if [ "$EVENT_NAME" = "repository_dispatch" ]; then
printf 'Triggered by upstream release: %s %s\n' "$UPSTREAM_PACKAGE" "$UPSTREAM_REF"
printf '### Triggered by upstream release: `%s %s`\n' "$UPSTREAM_PACKAGE" "$UPSTREAM_REF" >> "$GITHUB_STEP_SUMMARY"
fi

- name: Checkout
uses: actions/checkout@v4

Expand All @@ -37,30 +42,94 @@ jobs:
python-version: "3.12"

- name: Install dependencies
run: pip install pycubrid sqlalchemy sqlalchemy-cubrid
run: |
pip install pycubrid sqlalchemy sqlalchemy-cubrid
# Extra deps used by templates (e.g. batch-etl). Installed separately
# so the released pycubrid/sqlalchemy-cubrid above are not overridden
# by the git-pinned versions in the template requirements files.
pip install pandas matplotlib
# cubrid-mcp-server publishes to PyPI on release; fall back to git
# until the first PyPI release is available.
pip install cubrid-mcp-server \
|| pip install "git+https://github.com/cubrid-lab/cubrid-mcp-server.git"

- name: Record tested versions
run: |
{
echo "### Tested upstream versions";
echo "";
echo "| Package | Version |";
echo "| --- | --- |";
for pkg in pycubrid sqlalchemy-cubrid cubrid-mcp-server; do
ver=$(pip show "$pkg" | awk -F': ' '/^Version:/ {print $2}');
echo "| $pkg | ${ver:-not installed} |";
done
} | tee -a "$GITHUB_STEP_SUMMARY"

- name: Start CUBRID container
run: |
docker run -d --name cubrid --shm-size 512m \
-e CUBRID_DB=testdb \
-p 33000:33000 -p 1523:1523 \
cubrid/cubrid:11.2

- name: Wait for CUBRID readiness
run: |
for i in $(seq 1 30); do
if python -c "
from pycubrid import connect
try:
conn = connect('CUBRID:localhost:33000:testdb:::', user='dba', password='')
cur = conn.cursor()
cur.execute('SELECT 1')
cur.close()
conn.close()
print('CUBRID ready')
exit(0)
except Exception as e:
print(f'Not ready: {e}')
exit(1)
"; then
break
# Use the in-container csql check (same as docker-compose healthcheck)
# rather than a client connect; the broker accepts external TCP
# connections slightly after the engine itself is ready.
for i in $(seq 1 60); do
if docker exec cubrid bash -lc "csql -u dba testdb -c 'SELECT 1;'" >/dev/null 2>&1; then
echo "CUBRID testdb is ready (attempt $i)"
exit 0
fi
echo "Waiting... ($i/30)"
echo "Waiting for CUBRID testdb... attempt $i/60"
sleep 5
done
echo "CUBRID testdb did not become ready in time"
docker logs cubrid || true
exit 1

- name: Verify external client connectivity
run: |
# The csql check only proves the in-container engine is up; the broker
# accepts external TCP on the mapped port slightly later. Confirm a real
# pycubrid client (the same path the examples use) can connect before
# running make verify, otherwise the first example races the broker.
python - <<'PY'
import sys
import time

import pycubrid

last_error = None
for attempt in range(1, 31):
try:
conn = pycubrid.connect(
host="localhost",
port=33000,
database="testdb",
user="dba",
password="",
)
cursor = conn.cursor()
cursor.execute("SELECT 1")
cursor.fetchone()
cursor.close()
conn.close()
print(f"External pycubrid connection OK (attempt {attempt})")
sys.exit(0)
except Exception as error:
last_error = error
print(f"Waiting for external broker... attempt {attempt}/30: {error}")
time.sleep(3)

print(f"External broker not reachable in time: {last_error}")
sys.exit(1)
PY

- name: Run make verify
run: make verify

- name: Smoke test cubrid-mcp-server
run: python -c "import cubrid_mcp_server; print('cubrid-mcp-server import OK')"
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,12 @@ All examples connect to the same CUBRID instance:
```python
# pycubrid (direct)
import pycubrid

conn = pycubrid.connect(host="localhost", port=33000, database="testdb", user="dba")

# SQLAlchemy
from sqlalchemy import create_engine

engine = create_engine("cubrid+pycubrid://dba@localhost:33000/testdb")
```

Expand Down
12 changes: 6 additions & 6 deletions fundamentals/lob-handling/expected/06_lob.expected
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
=== CLOB (Character Large Object) ===
✓ Inserted 3 documents with CLOB data
README (64 chars): # CUBRID Cookbook

A collection of examples for CUBRID datab...
License (46 chars): Apache License 2.0

Copyright 2026 cubrid-labs
README (66 chars): # CUBRID Cookbook\
\
A collection of examples for CUBRID dat...
License (47 chars): Apache License 2.0\
\
Copyright 2026 cubrid-lab
Long Text (2,800 chars): Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet. Lore...

=== BLOB (Binary Large Object) ===
Expand Down
2 changes: 1 addition & 1 deletion fundamentals/orm-basics/expected/06_reflection.expected
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@
cookbook_ref_articles PK: ['id']

Foreign keys on cookbook_ref_articles:
['author_id'] → cookbook_ref_authors.['id']

=== Reflect Indexes ===
Indexes on cookbook_ref_articles:
fk_cookbook_ref_articles_author_id columns=['author_id']
idx_articles_views columns=['views']

=== Autoload Table ===
Expand Down
3 changes: 3 additions & 0 deletions performance/bulk-insert/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Inserting 10,000 rows with individual COMMITs takes **~470 seconds** (7.8 minute

```python
"""Reduce transaction overhead by committing in batches."""

from __future__ import annotations

import pycubrid
Expand Down Expand Up @@ -58,6 +59,7 @@ conn.close()

```python
"""Insert multiple rows in a single call."""

from __future__ import annotations

import pycubrid
Expand All @@ -81,6 +83,7 @@ conn.close()

```python
"""Bulk insert using SQLAlchemy Core (faster than ORM object creation)."""

from __future__ import annotations

from sqlalchemy import create_engine
Expand Down
16 changes: 12 additions & 4 deletions performance/bulk-insert/benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,18 @@ def main() -> None:

# Print results
print("\n=== Bulk Insert Benchmark ===")
print(f"Strategy 1 (per-row COMMIT): {strategy1_time:.3f}s {per_row_count} rows ({strategy1_rows_sec:.0f} rows/sec)")
print(f"Strategy 2 (batch COMMIT 500): {strategy2_time:.3f}s {num_rows} rows ({strategy2_rows_sec:.0f} rows/sec)")
print(f"Strategy 3 (single COMMIT): {strategy3_time:.3f}s {num_rows} rows ({strategy3_rows_sec:.0f} rows/sec)")
print(f"\nKey insight: COMMIT is ~{strategy1_time / per_row_count * 1000:.0f}ms per call — batch your writes!")
print(
f"Strategy 1 (per-row COMMIT): {strategy1_time:.3f}s {per_row_count} rows ({strategy1_rows_sec:.0f} rows/sec)"
)
print(
f"Strategy 2 (batch COMMIT 500): {strategy2_time:.3f}s {num_rows} rows ({strategy2_rows_sec:.0f} rows/sec)"
)
print(
f"Strategy 3 (single COMMIT): {strategy3_time:.3f}s {num_rows} rows ({strategy3_rows_sec:.0f} rows/sec)"
)
print(
f"\nKey insight: COMMIT is ~{strategy1_time / per_row_count * 1000:.0f}ms per call — batch your writes!"
)
print()

finally:
Expand Down
12 changes: 7 additions & 5 deletions performance/connection-pooling/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,17 @@ In a web application creating a new connection per request:

```python
"""Use SQLAlchemy's built-in connection pool."""

from __future__ import annotations

from sqlalchemy import create_engine, text

engine = create_engine(
"cubrid+pycubrid://dba@localhost:33000/testdb",
pool_size=5, # Number of connections to keep in the pool
max_overflow=10, # Extra connections allowed beyond pool_size
pool_timeout=30, # Max seconds to wait for a connection
pool_recycle=3600, # Recreate connections after 1 hour (handles CUBRID idle timeout)
pool_size=5, # Number of connections to keep in the pool
max_overflow=10, # Extra connections allowed beyond pool_size
pool_timeout=30, # Max seconds to wait for a connection
pool_recycle=3600, # Recreate connections after 1 hour (handles CUBRID idle timeout)
pool_pre_ping=True, # Validate connections before use
)

Expand All @@ -41,6 +42,7 @@ with engine.connect() as conn:

```python
"""Per-request session management in FastAPI."""

from __future__ import annotations

from collections.abc import Generator
Expand Down Expand Up @@ -93,7 +95,7 @@ Set `pool_recycle` shorter than the server timeout to avoid stale connections.
# Set pool_recycle well below that (safety margin)
engine = create_engine(
"cubrid+pycubrid://dba@localhost:33000/testdb",
pool_recycle=3600, # 1 hour — 1/6 of server timeout
pool_recycle=3600, # 1 hour — 1/6 of server timeout
pool_pre_ping=True, # Detect broken connections before use
)
```
Expand Down
2 changes: 1 addition & 1 deletion performance/connection-pooling/benchmark.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import time
from sqlalchemy import create_engine, text
from sqlalchemy.pool import NullPool, QueuePool
from sqlalchemy.pool import NullPool


def main() -> None:
Expand Down
5 changes: 3 additions & 2 deletions performance/fetch-optimization/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,15 @@ rows = cursor.fetchall()
# Don't fetch 100K rows at once — paginate on the server
PAGE_SIZE = 1000


def fetch_page(cursor, offset: int, limit: int) -> list:
cursor.execute(
"SELECT order_id, total_amt FROM cookbook_orders "
"ORDER BY order_id LIMIT ?, ?",
"SELECT order_id, total_amt FROM cookbook_orders ORDER BY order_id LIMIT ?, ?",
(offset, limit),
)
return cursor.fetchall()


# Usage
page = fetch_page(cursor, offset=0, limit=PAGE_SIZE)
```
Expand Down
5 changes: 4 additions & 1 deletion pitfalls/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ engine = create_engine(
)
SessionLocal = sessionmaker(bind=engine)


@app.get("/items")
def list_items():
with SessionLocal() as session:
Expand Down Expand Up @@ -158,7 +159,7 @@ session.execute(stmt)
# ❌ Anti-pattern — sync DB call in async handler blocks the event loop
@app.get("/items")
async def list_items():
conn = pycubrid.connect(...) # Blocks event loop
conn = pycubrid.connect(...) # Blocks event loop
cursor = conn.cursor()
cursor.execute("SELECT * FROM cookbook_items") # Blocks event loop
rows = cursor.fetchall()
Expand All @@ -175,12 +176,14 @@ async def list_items():
def list_items(db: Session = Depends(get_db)):
return db.execute(text("SELECT * FROM cookbook_items")).all()


# ✅ Option B — explicit thread pool for async handler
import asyncio
from concurrent.futures import ThreadPoolExecutor

executor = ThreadPoolExecutor(max_workers=5)


@app.get("/items")
async def list_items():
loop = asyncio.get_event_loop()
Expand Down
16 changes: 16 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
[tool.ruff]
line-length = 100
target-version = "py310"

[tool.ruff.lint]
# Pin the rule set explicitly instead of inheriting ruff's implicit defaults.
# Ruff expanded its default selection in 0.16 (59 -> 413 rules against this
# config), which turns every routine ruff bump into a CI-breaking change
# unrelated to our own code. These four groups are exactly what ruff selected
# by default through 0.15.x.
select = ["E4", "E7", "E9", "F"]

[tool.ruff.lint.per-file-ignores]
# Test suites bootstrap sys.path before importing the module under test,
# so their imports are intentionally not at the top of the file.
"**/tests/*.py" = ["E402"]
# Flask template app.py files intentionally place demo imports mid-file to
# illustrate patterns; moving them would change the teaching structure.
"templates/flask/**/app.py" = ["E402"]
3 changes: 2 additions & 1 deletion scripts/normalize_output.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ sed -E \
-e 's/[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]+/{{TIMESTAMP}}/g' \
-e 's/[{][{]DATE[}][}] [0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]+/{{TIMESTAMP}}/g' \
-e 's/[{][{]DATE[}][}]T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]+/{{DATETIME}}/g' \
-e 's/\[generated in [0-9.]+s]/[generated in {{TIME}}s]/g'
-e 's/\[generated in [0-9.]+s]/[generated in {{TIME}}s]/g' \
-e "s/ \(errno=-?[0-9]+, description='[^']*', sqlstate='[^']*'\)//g"
1 change: 1 addition & 0 deletions templates/api-service-fastapi/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class CookbookCategory(Base):
# passive_deletes removed — CUBRID FK cascade support varies (issue #33).
)


class CookbookItem(Base):
__tablename__ = "cookbook_items"

Expand Down
Loading
Loading