Skip to content

Commit 96aa472

Browse files
authored
Merge pull request #3 from gitmobkab/unique_constraint_feature
this is just to merge the changes for the new unique constraint feature
2 parents 3f42937 + b4e0ba9 commit 96aa472

29 files changed

Lines changed: 548 additions & 190 deletions

.github/workflows/ci.yml

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,10 @@ on:
77
branches: [main]
88

99
jobs:
10-
lint-and-test:
10+
lint:
1111
runs-on: ubuntu-latest
12-
1312
steps:
1413
- uses: actions/checkout@v4
15-
with:
16-
fetch-depth: 0
17-
fetch-tags: true
1814

1915
- name: Install uv
2016
uses: astral-sh/setup-uv@v5
@@ -28,5 +24,71 @@ jobs:
2824
- name: Lint with ruff
2925
run: uv run ruff check src/
3026

27+
test:
28+
runs-on: ubuntu-latest
29+
needs: lint
30+
31+
services:
32+
mysql:
33+
image: mysql:8.0
34+
env:
35+
MYSQL_ROOT_PASSWORD: root
36+
MYSQL_DATABASE: copia_test
37+
ports:
38+
- 3306:3306
39+
options: >-
40+
--health-cmd="mysqladmin ping"
41+
--health-interval=10s
42+
--health-timeout=5s
43+
--health-retries=3
44+
45+
postgres:
46+
image: postgres:16
47+
env:
48+
POSTGRES_PASSWORD: root
49+
POSTGRES_DB: copia_test
50+
ports:
51+
- 5432:5432
52+
options: >-
53+
--health-cmd="pg_isready"
54+
--health-interval=10s
55+
--health-timeout=5s
56+
--health-retries=3
57+
58+
env:
59+
COPIA_TEST_MYSQL_HOST: 127.0.0.1
60+
COPIA_TEST_MYSQL_PORT: 3306
61+
COPIA_TEST_MYSQL_USER: root
62+
COPIA_TEST_MYSQL_PASSWORD: root
63+
COPIA_TEST_MYSQL_DB: copia_test
64+
COPIA_TEST_PG_HOST: 127.0.0.1
65+
COPIA_TEST_PG_PORT: 5432
66+
COPIA_TEST_PG_USER: postgres
67+
COPIA_TEST_PG_PASSWORD: root
68+
COPIA_TEST_PG_DB: copia_test
69+
70+
steps:
71+
- uses: actions/checkout@v4
72+
with:
73+
fetch-depth: 0
74+
fetch-tags: true
75+
76+
- name: Install uv
77+
uses: astral-sh/setup-uv@v5
78+
79+
- name: Set up Python
80+
run: uv python install 3.13
81+
82+
- name: Install dependencies and all extras
83+
run: uv sync --all-groups --all-extras
84+
85+
- name: Set up MySQL schema
86+
run: |
87+
mysql -h 127.0.0.1 -u root -proot copia_test < tests/adapters/schema.sql
88+
89+
- name: Set up PostgreSQL schema
90+
run: |
91+
PGPASSWORD=root psql -h 127.0.0.1 -U postgres -d copia_test -f tests/adapters/schema.sql
92+
3193
- name: Run tests
3294
run: uv run pytest tests/ --cov=src/copia --cov-report=term-missing

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Added
11+
12+
- official postgres adapter
13+
- unique keyword and generated values
14+
15+
### Changed
16+
17+
- the dsl syntax now explicitly require parantheses after the name of a generator
18+
- the `ref` generator is now called `fetch` as the name is more intuitive
19+
- the cli logs messages appereance
20+
21+
### Removed
22+
23+
- tests on profile keys `host`, `database` and `user` for more flexibility
24+
825
## [0.2.1] - 2026-04-26
926

1027
### Fixed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ created_at: past_date()
7979

8080
- **Simple language** — if you know what a function call looks like, you know the Copia DSL
8181
- **Realistic data** — built on [Faker](https://faker.readthedocs.io/), 25+ generators out of the box
82-
- **Relational-aware**[`ref('table.column')`](https://gitmobkab.github.io/copia/generators/ref) samples from existing rows, so foreign keys just work
82+
- **Relational-aware**[`fetch('table.column')`](https://gitmobkab.github.io/copia/generators/fetch) samples from existing rows, so foreign keys just work
8383
- **Interactive TUI** — write, preview, and insert without leaving your terminal
8484

8585
## Installation

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ Run it for as many rows as you need. Thanks to Faker every value is realistic, f
5252
- **Framework-agnostic.** Works on any relational database, any stack.
5353
- **Simple language.** If you know what a function call looks like, you know the Copia DSL.
5454
- **Realistic data.** Built on [Faker](https://faker.readthedocs.io/) — names, emails, addresses, IPs, dates, and more out of the box.
55-
- **Relational-aware.** The [`ref()`](generators/ref.md) generator lets you sample from existing rows, so foreign keys just work.
55+
- **Relational-aware.** The [`fetch()`](generators/fetch.md) generator lets you sample from existing rows, so foreign keys just work.
5656
- **Interactive.** A full TUI lets you write, preview, and insert without leaving your terminal.
5757

5858
---

layouts/social_card_bg.png

1.36 MB
Loading

mkdocs.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ plugins:
7676
- meta
7777
- social:
7878
cards_layout_options:
79-
background_color: "#0f1210"
79+
background_color: "transparent"
80+
background_image: layouts/social_card_bg.png
8081
color: "#e2ece2"
8182
font_family: JetBrains Mono
8283
- blog:

pyproject.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ dependencies = [
1616
"lark>=1.3.1",
1717
"pydantic>=2.12.5",
1818
"pymysql>=1.1.2",
19-
"sqlalchemy>=2.0.48",
2019
"textual>=8.2.1",
2120
"typer>=0.24.1",
2221
]

src/copia/adapters/base_adapter.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,30 @@ def ping(self) -> None: ...
1212
def get_tables(self) -> list[str]: ...
1313

1414
@abstractmethod
15-
def get_columns(self, table: str) -> list[ColumnInfo]: ...
15+
def get_columns(self, table: str) -> list[ColumnInfo]:
16+
self.check_table(table)
1617

1718
@abstractmethod
18-
def fetch(self, table: str, columns: Sequence[str]) -> list[tuple[Any, ...]]: ...
19+
def fetch(self, table: str, columns: Sequence[str]) -> list[tuple[Any, ...]]:
20+
self.check_columns_in_table(table, columns)
1921

2022
@abstractmethod
21-
def insert(self, table: str, rows: Sequence[dict[str, Any]], batch_size: int = 200) -> None: ...
23+
def insert(self, table: str, rows: Sequence[dict[str, Any]], batch_size: int = 200) -> None:
24+
if not rows:
25+
raise ValueError("rows cannot be empty")
26+
columns = list(rows[0].keys())
27+
self.check_columns_in_table(table, columns)
2228

2329
@abstractmethod
24-
def close(self) -> None: ...
30+
def close(self) -> None: ...
31+
32+
def check_table(self, table: str) -> None:
33+
if table not in self.get_tables():
34+
raise ValueError(f'The table {table!r} does not exist in the current db')
35+
36+
def check_columns_in_table(self, table: str, columns: Sequence[str]) -> None:
37+
self.check_table(table)
38+
columns_names = [column.name for column in self.get_columns(table)]
39+
for column in columns:
40+
if column not in columns_names:
41+
raise ValueError(f'The column {column!r} in table {table!r} does not exist')

src/copia/adapters/mysql_adapter.py

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def get_tables(self) -> list[str]:
3939
return tables
4040

4141
def get_columns(self, table: str) -> list[ColumnInfo]:
42-
self.check_table(table)
42+
super().get_columns(table)
4343
columns : list[ColumnInfo] = []
4444
with self._connection.cursor() as cursor:
4545
cursor.execute(f"SHOW COLUMNS FROM {table}")
@@ -55,51 +55,38 @@ def get_columns(self, table: str) -> list[ColumnInfo]:
5555
return columns
5656

5757
def fetch(self, table: str, columns: Sequence[str]) -> list[tuple[Any, ...]]:
58-
self.check_columns_in_table(table, columns)
58+
super().fetch(table, columns)
5959
columns_query = ", ".join(columns)
6060
with self._connection.cursor(Cursor) as cursor:
6161
cursor.execute(f"SELECT {columns_query} FROM {table}")
6262
return list(cursor.fetchall())
6363

6464
def insert(self, table: str, rows: Sequence[dict[str, Any]], batch_size: int = 200) -> None:
65-
if not rows:
66-
return
65+
super().insert(table, rows)
6766
columns = list(rows[0].keys())
68-
self.check_columns_in_table(table, columns)
6967
columns_query = ", ".join(columns)
7068
placeholders = ", ".join(f"%({c})s" for c in columns)
7169
query = f"INSERT INTO {table} ({columns_query}) VALUES ({placeholders})"
7270

7371
iterator = iter(rows)
7472
with self._connection.cursor() as cursor:
7573
while batch := list(islice(iterator, batch_size)):
76-
coerced_batch = list(map(self.coerce_row, batch))
74+
coerced_batch = list(map(self._coerce_row, batch))
7775
cursor.executemany(query, coerced_batch)
7876
self._connection.commit()
7977

80-
def coerce_row(self, row: dict[str, Any]) -> dict[str, Any]:
78+
def _coerce_row(self, row: dict[str, Any]) -> dict[str, Any]:
8179
coerced_row: dict[str, Any] = {}
82-
for key, value in row:
83-
coerced_row[key] = self.coerce(value)
80+
for key, value in row.items():
81+
coerced_row[key] = self._coerce(value)
8482
return coerced_row
8583

8684

87-
def coerce(self, value: Any) -> Any:
85+
def _coerce(self, value: Any) -> Any:
8886
type_of_value = type(value)
8987
if coercer := self.COERCERS.get(type_of_value):
9088
return coercer(value)
9189
return value
9290

9391
def close(self) -> None:
94-
self._connection.close()
95-
96-
def check_table(self, table: str) -> None:
97-
if table not in self.get_tables():
98-
raise ValueError(f'The table {table!r} does not exist in the current db')
99-
100-
def check_columns_in_table(self, table: str, columns: Sequence[str]) -> None:
101-
self.check_table(table)
102-
real_columns_names = [column.name for column in self.get_columns(table)]
103-
for column in columns:
104-
if column not in real_columns_names:
105-
raise ValueError(f'The column {column!r} in table {table!r} does not exist')
92+
self._connection.close()

src/copia/adapters/postgres_adapter.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ def get_tables(self) -> list[str]:
2727
return [row[0] for row in cursor.fetchall()]
2828

2929
def get_columns(self, table: str) -> list[ColumnInfo]:
30+
super().get_columns(table)
3031
columns: list[ColumnInfo] = []
3132
with self._connection.cursor() as cursor:
3233
cursor.execute(
@@ -47,6 +48,7 @@ def get_columns(self, table: str) -> list[ColumnInfo]:
4748
return columns
4849

4950
def fetch(self, table: str, columns: Sequence[str]) -> list[tuple[Any, ...]]:
51+
super().fetch(table, columns)
5052
query = SQL("SELECT {0} FROM {1}")
5153
columns_query = self.escape_columns(columns)
5254
composed_query = query.format(
@@ -58,8 +60,7 @@ def fetch(self, table: str, columns: Sequence[str]) -> list[tuple[Any, ...]]:
5860
return cursor.fetchall()
5961

6062
def insert(self, table: str, rows: Sequence[dict[str, Any]], batch_size: int = 200) -> None:
61-
if not rows:
62-
return
63+
super().insert(table, rows)
6364
columns = list(rows[0].keys())
6465
query = SQL("INSERT INTO {0} ({1}) VALUES ({2})")
6566
columns = list(rows[0].keys())

0 commit comments

Comments
 (0)