Skip to content

Commit 9fc5bbb

Browse files
committed
feat: implement SQLiteAdapter for SQLite database support
1 parent 6ece12d commit 9fc5bbb

2 files changed

Lines changed: 93 additions & 4 deletions

File tree

src/copia/adapters/__init__.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
from .base_adapter import BaseAdapter
2-
from copia.cli.config import Profile
2+
from copia.cli.config import BaseProfile
33

44

5-
def get_adapter(profile: Profile) -> BaseAdapter: # type: ignore
5+
def get_adapter(profile: BaseProfile) -> BaseAdapter:
66
profile_info = get_profile_info(profile)
77
if profile.adapter == "mysql":
88
from .mysql_adapter import MySQLAdapter
99
return MySQLAdapter(**profile_info)
1010
elif profile.adapter == "postgres":
1111
from .postgres_adapter import PostgresAdapter
1212
return PostgresAdapter(**profile_info)
13-
13+
elif profile.adapter == "sqlite":
14+
from .sqlite_adapter import SQLiteAdapter
15+
return SQLiteAdapter(**profile_info)
16+
raise ValueError(f"unknown adapter, {profile.adapter}")
1417

15-
def get_profile_info(profile: Profile) -> dict:
18+
def get_profile_info(profile: BaseProfile) -> dict:
1619
return profile.model_dump(exclude={"adapter"})
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
from typing import Any, Sequence, Type, Callable
2+
from itertools import islice
3+
from uuid import UUID
4+
import sqlite3
5+
6+
from .base_adapter import BaseAdapter
7+
from .models import ColumnInfo
8+
9+
10+
class SQLiteAdapter(BaseAdapter):
11+
12+
COERCERS: dict[Type, Callable[..., Any]] = {
13+
UUID: str
14+
}
15+
16+
def __init__(self, database: str) -> None:
17+
self._connection = sqlite3.connect(database)
18+
# SQLite disables FK enforcement per-connection by default;
19+
# turn it on so seeded data behaves like Postgres/MySQL.
20+
self._connection.execute("PRAGMA foreign_keys = ON")
21+
22+
def ping(self) -> None:
23+
self._connection.execute("SELECT 1")
24+
25+
def get_tables(self) -> list[str]:
26+
cursor = self._connection.execute(
27+
"SELECT name FROM sqlite_master "
28+
"WHERE type = 'table' AND name NOT LIKE 'sqlite_%'"
29+
)
30+
return [row[0] for row in cursor.fetchall()]
31+
32+
def get_columns(self, table: str) -> list[ColumnInfo]:
33+
super().get_columns(table)
34+
columns: list[ColumnInfo] = []
35+
quoted_table = self._quote_identifier(table)
36+
cursor = self._connection.execute(f"PRAGMA table_info({quoted_table})")
37+
for _, name, col_type, notnull, default, pk in cursor.fetchall():
38+
columns.append(ColumnInfo(
39+
name=name,
40+
type=col_type or "",
41+
is_nullable=not bool(notnull),
42+
default=default,
43+
extra="PRIMARY KEY" if pk else None
44+
))
45+
return columns
46+
47+
def fetch(self, table: str, columns: Sequence[str]) -> list[tuple[Any, ...]]:
48+
super().fetch(table, columns)
49+
columns_query = ", ".join(self._quote_identifier(col) for col in columns)
50+
quoted_table = self._quote_identifier(table)
51+
cursor = self._connection.execute(f"SELECT {columns_query} FROM {quoted_table}")
52+
return cursor.fetchall()
53+
54+
def insert(self, table: str, rows: Sequence[dict[str, Any]], batch_size: int = 200) -> None:
55+
super().insert(table, rows)
56+
columns = list(rows[0].keys())
57+
columns_query = ", ".join(self._quote_identifier(c) for c in columns)
58+
placeholders = ", ".join(f":{c}" for c in columns)
59+
quoted_table = self._quote_identifier(table)
60+
query = f"INSERT INTO {quoted_table} ({columns_query}) VALUES ({placeholders})"
61+
62+
iterator = iter(rows)
63+
try:
64+
while batch := list(islice(iterator, batch_size)):
65+
coerced_batch = list(map(self._coerce_row, batch))
66+
self._connection.executemany(query, coerced_batch)
67+
self._connection.commit()
68+
except Exception as err:
69+
self._connection.rollback()
70+
raise err
71+
72+
def close(self) -> None:
73+
self._connection.close()
74+
75+
def _coerce_row(self, row: dict[str, Any]) -> dict[str, Any]:
76+
return {key: self._coerce(value) for key, value in row.items()}
77+
78+
def _coerce(self, value: Any) -> Any:
79+
coercer = self.COERCERS.get(type(value))
80+
return coercer(value) if coercer else value
81+
82+
@staticmethod
83+
def _quote_identifier(identifier: str) -> str:
84+
# Standard SQL identifier escaping: wrap in double quotes,
85+
# double any embedded double quotes.
86+
return '"' + identifier.replace('"', '""') + '"'

0 commit comments

Comments
 (0)