Skip to content

Commit 4a75c82

Browse files
committed
feat: add moss-connector-postgres
Add PostgreSQL source connector for Moss that reads rows from a PostgreSQL database via psycopg (v3) and yields DocumentInfo objects. Supports regular Postgres, Neon, Supabase (direct URL), CockroachDB, Amazon RDS, Timescale, and pgvector. - PostgresConnector with DSN-based connection - Uses psycopg.rows.dict_row for dict-keyed row output - Unit tests with mocked psycopg connection (4 tests passing) - Integration test boilerplate (skips without POSTGRES_DSN) Closes #168
1 parent f5323a1 commit 4a75c82

8 files changed

Lines changed: 508 additions & 10 deletions

File tree

packages/moss-data-connector/README.md

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ moss-data-connector/
1111
├── moss-connector-mongodb/ # MongoDB source (requires pymongo)
1212
├── moss-connector-mysql/ # MySQL / MariaDB source (requires pymysql)
1313
├── moss-connector-supabase/ # Supabase source (requires supabase)
14-
└── moss-connector-dynamodb/ # Amazon DynamoDB source (requires boto3)
14+
├── moss-connector-dynamodb/ # Amazon DynamoDB source (requires boto3)
15+
└── moss-connector-postgres/ # PostgreSQL source (requires psycopg)
1516
```
1617

1718

@@ -35,15 +36,15 @@ Use `auto_id=True` when your mapper does not have a stable primary key and you w
3536

3637
## Available connectors
3738

38-
| Package | Source | Extra driver |
39-
| ---------------------------------------------------------- | ------------- | ------------ |
40-
| [`moss-connector-sqlite`](moss-connector-sqlite) | SQLite ||
41-
| [`moss-connector-mongodb`](moss-connector-mongodb) | MongoDB | `pymongo` |
42-
| [`moss-connector-mysql`](moss-connector-mysql) | MySQL | `pymysql` |
43-
| [`moss-connector-supabase`](moss-connector-supabase) | Supabase | `supabase` |
44-
| [`moss-connector-dynamodb`](moss-connector-dynamodb) | Amazon DynamoDB | `boto3` |
39+
| Package | Source | Extra driver |
40+
| ---------------------------------------------------------- | --------------- | ------------ |
41+
| [`moss-connector-sqlite`](moss-connector-sqlite) | SQLite ||
42+
| [`moss-connector-mongodb`](moss-connector-mongodb) | MongoDB | `pymongo` |
43+
| [`moss-connector-mysql`](moss-connector-mysql) | MySQL | `pymysql` |
44+
| [`moss-connector-supabase`](moss-connector-supabase) | Supabase | `supabase` |
45+
| [`moss-connector-dynamodb`](moss-connector-dynamodb) | Amazon DynamoDB | `boto3` |
46+
| [`moss-connector-postgres`](moss-connector-postgres) | PostgreSQL | `psycopg` |
4547

4648
## Adding a new connector
4749

48-
See [`_template/README.md`](_template/README.md).
49-
50+
See [`_template/README.md`](_template/README.md).
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# moss-connector-postgres
2+
3+
PostgreSQL source connector for Moss. Uses [psycopg](https://www.psycopg.org/psycopg3/) (v3) so it works against regular Postgres, Neon, Supabase (direct URL), CockroachDB, Amazon RDS, Timescale, and pgvector.
4+
5+
## Install
6+
7+
```bash
8+
pip install moss-connector-postgres
9+
```
10+
11+
This installs `psycopg[binary]` automatically.
12+
13+
## Usage
14+
15+
```python
16+
import asyncio
17+
from moss import DocumentInfo
18+
from moss_connector_postgres import PostgresConnector, ingest
19+
20+
async def main():
21+
source = PostgresConnector(
22+
dsn="postgresql://user:pass@localhost:5432/mydb",
23+
query="SELECT id, title, body FROM articles",
24+
mapper=lambda row: DocumentInfo(
25+
id=str(row["id"]),
26+
text=row["body"],
27+
metadata={"title": row["title"]},
28+
),
29+
)
30+
31+
result = await ingest(
32+
source,
33+
project_id="your_project_id",
34+
project_key="your_project_key",
35+
index_name="articles",
36+
)
37+
print(f"copied {result.doc_count} rows")
38+
39+
asyncio.run(main())
40+
```
41+
42+
### DSN formats
43+
44+
The connector accepts any standard PostgreSQL connection string:
45+
46+
```python
47+
# Local Postgres
48+
PostgresConnector(dsn="postgresql://user:pass@localhost:5432/mydb", ...)
49+
50+
# Neon serverless
51+
PostgresConnector(dsn="postgresql://user:pass@ep-xxx.us-east-2.aws.neon.tech/neondb", ...)
52+
53+
# Supabase (direct connection URL)
54+
PostgresConnector(dsn="postgresql://postgres:pass@db.xxx.supabase.co:5432/postgres", ...)
55+
```
56+
57+
## Layout
58+
59+
```
60+
src/
61+
├── __init__.py # re-exports PostgresConnector and ingest
62+
├── connector.py # PostgresConnector class
63+
└── ingest.py # ingest() - keep in sync with the other connector packages
64+
```
65+
66+
## Tests
67+
68+
```bash
69+
pip install -e ".[dev]"
70+
pytest tests/test_postgres.py -v # mocked, no network or DB needed
71+
pytest tests/test_integration_postgres_moss.py -v -s # live Postgres + Moss (requires POSTGRES_DSN, MOSS_PROJECT_ID, MOSS_PROJECT_KEY)
72+
```
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
[project]
2+
name = "moss-connector-postgres"
3+
version = "0.0.1"
4+
description = "PostgreSQL source connector for Moss. Uses psycopg (v3) so it works against Postgres, Neon, Supabase (direct URL), CockroachDB, Amazon RDS, Timescale, and pgvector."
5+
readme = "README.md"
6+
requires-python = ">=3.10,<3.15"
7+
license = { text = "BSD-2-Clause" }
8+
authors = [{ name = "InferEdge Inc.", email = "contact@moss.dev" }]
9+
keywords = ["moss", "connectors", "ingest", "postgres", "postgresql"]
10+
classifiers = [
11+
"Development Status :: 3 - Alpha",
12+
"Intended Audience :: Developers",
13+
"License :: OSI Approved :: BSD License",
14+
"Programming Language :: Python :: 3",
15+
"Programming Language :: Python :: 3.10",
16+
"Programming Language :: Python :: 3.11",
17+
"Programming Language :: Python :: 3.12",
18+
"Programming Language :: Python :: 3.13",
19+
"Topic :: Database",
20+
]
21+
dependencies = [
22+
"moss>=1.1.1",
23+
"psycopg[binary]>=3.1",
24+
]
25+
26+
[project.optional-dependencies]
27+
dev = [
28+
"pytest>=8.0.0",
29+
"pytest-asyncio>=0.23.0",
30+
"python-dotenv>=1.0.0",
31+
"ruff>=0.5.0",
32+
]
33+
34+
[project.urls]
35+
Homepage = "https://github.com/usemoss/moss"
36+
Repository = "https://github.com/usemoss/moss"
37+
Source = "https://github.com/usemoss/moss/tree/main/packages/moss-data-connector/moss-connector-postgres"
38+
39+
[build-system]
40+
requires = ["setuptools>=61.0"]
41+
build-backend = "setuptools.build_meta"
42+
43+
[tool.setuptools]
44+
packages = ["moss_connector_postgres"]
45+
package-dir = { "moss_connector_postgres" = "src" }
46+
47+
[tool.ruff]
48+
line-length = 100
49+
target-version = "py310"
50+
51+
[tool.ruff.lint]
52+
select = ["E", "W", "F", "I", "B", "UP"]
53+
54+
[tool.pytest.ini_options]
55+
asyncio_mode = "auto"
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""PostgreSQL source connector for Moss.
2+
3+
from moss_connector_postgres import PostgresConnector, ingest
4+
"""
5+
6+
from .connector import PostgresConnector
7+
from .ingest import ingest
8+
9+
__all__ = ["PostgresConnector", "ingest"]
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""PostgreSQL connector.
2+
3+
Reads rows from a PostgreSQL database via ``psycopg`` (v3) and yields one
4+
``DocumentInfo`` per row. Uses ``DictRow`` factory so every row is a plain dict
5+
keyed by column name.
6+
7+
Works against regular Postgres, Neon, Supabase (direct URL), CockroachDB,
8+
Amazon RDS, Timescale, and pgvector.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from collections.abc import Callable, Iterator
14+
from typing import Any
15+
16+
import psycopg
17+
import psycopg.rows
18+
from moss import DocumentInfo
19+
20+
21+
class PostgresConnector:
22+
"""Run a SELECT against a PostgreSQL database and yield one
23+
``DocumentInfo`` per row.
24+
25+
``mapper`` turns a row (dict of column → value) into a ``DocumentInfo``;
26+
the caller decides which columns become id / text / metadata / embedding.
27+
"""
28+
29+
def __init__(
30+
self,
31+
dsn: str,
32+
query: str,
33+
mapper: Callable[[dict[str, Any]], DocumentInfo],
34+
) -> None:
35+
self.dsn = dsn
36+
self.query = query
37+
self.mapper = mapper
38+
39+
def __iter__(self) -> Iterator[DocumentInfo]:
40+
conn = psycopg.connect(self.dsn, row_factory=psycopg.rows.dict_row)
41+
try:
42+
with conn.cursor() as cursor:
43+
cursor.execute(self.query)
44+
for row in cursor:
45+
yield self.mapper(row)
46+
finally:
47+
conn.close()
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Copy rows into a Moss index."""
2+
3+
from __future__ import annotations
4+
5+
import uuid
6+
from collections.abc import Iterable
7+
8+
from moss import DocumentInfo, MossClient, MutationResult
9+
10+
11+
def _replace_doc_id(doc: DocumentInfo) -> DocumentInfo:
12+
return DocumentInfo(
13+
id=str(uuid.uuid4()),
14+
text=doc.text,
15+
metadata=getattr(doc, "metadata", None),
16+
embedding=getattr(doc, "embedding", None),
17+
)
18+
19+
20+
async def ingest(
21+
source: Iterable[DocumentInfo],
22+
project_id: str,
23+
project_key: str,
24+
index_name: str,
25+
model_id: str | None = None,
26+
auto_id: bool = False,
27+
) -> MutationResult | None:
28+
"""Copy every `DocumentInfo` from `source` into a fresh Moss index."""
29+
if auto_id:
30+
docs = [_replace_doc_id(doc) for doc in source]
31+
else:
32+
docs = list(source)
33+
if not docs:
34+
return None
35+
client = MossClient(project_id, project_key)
36+
return await client.create_index(index_name, docs, model_id=model_id)
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""End-to-end integration test against a real PostgreSQL database and Moss project.
2+
3+
This test actually creates a temporary table in Postgres, ingests from it into a
4+
real Moss index, queries the index, and cleans up afterwards. It is SKIPPED
5+
unless POSTGRES_DSN, MOSS_PROJECT_ID, and MOSS_PROJECT_KEY are all set in the
6+
environment (or in a .env file at the repo root or package root).
7+
8+
POSTGRES_DSN should be a connection string like:
9+
postgresql://user:password@host:port/database
10+
11+
Run it with:
12+
cd packages/moss-data-connector/moss-connector-postgres
13+
pytest tests/test_integration_postgres_moss.py -v -s
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import os
19+
import uuid
20+
from pathlib import Path
21+
22+
import pytest
23+
24+
# Load .env from the package dir, then the repo root, if present.
25+
try:
26+
from dotenv import load_dotenv
27+
28+
_here = Path(__file__).resolve()
29+
for candidate in (
30+
_here.parents[1] / ".env", # this package's own .env
31+
_here.parents[2] / ".env", # moss-data-connector/.env
32+
_here.parents[4] / ".env", # <repo>/.env
33+
):
34+
if candidate.exists():
35+
load_dotenv(candidate, override=False)
36+
except ImportError:
37+
pass # dotenv is optional; env vars can also be set directly.
38+
39+
pytest.importorskip("psycopg")
40+
41+
import psycopg # noqa: E402
42+
import psycopg.rows # noqa: E402
43+
from moss import DocumentInfo, MossClient, QueryOptions # noqa: E402
44+
from moss_connector_postgres import PostgresConnector, ingest # noqa: E402
45+
46+
POSTGRES_DSN = os.getenv("POSTGRES_DSN")
47+
PROJECT_ID = os.getenv("MOSS_PROJECT_ID")
48+
PROJECT_KEY = os.getenv("MOSS_PROJECT_KEY")
49+
50+
pytestmark = pytest.mark.skipif(
51+
not (POSTGRES_DSN and PROJECT_ID and PROJECT_KEY),
52+
reason="Set POSTGRES_DSN, MOSS_PROJECT_ID, and MOSS_PROJECT_KEY to run the real integration test.",
53+
)
54+
55+
56+
@pytest.fixture()
57+
def postgres_table():
58+
"""Create a temporary table with 5 recognisable rows, drop it after the test."""
59+
table_name = f"moss_test_{uuid.uuid4().hex[:8]}"
60+
conn = psycopg.connect(POSTGRES_DSN)
61+
conn.autocommit = True
62+
try:
63+
with conn.cursor() as cur:
64+
cur.execute(
65+
f"CREATE TEMPORARY TABLE {table_name} "
66+
"(id INT PRIMARY KEY, title VARCHAR(255), body TEXT)"
67+
)
68+
cur.executemany(
69+
f"INSERT INTO {table_name} (id, title, body) VALUES (%s, %s, %s)",
70+
[
71+
(1, "Refund policy", "Refunds take 3 to 5 business days."),
72+
(2, "Shipping time", "Orders ship within 24 hours."),
73+
(3, "Contact support", "Reach support 24/7 via live chat."),
74+
(4, "Password reset", "Click the link on the login page."),
75+
(5, "Order tracking", "Tracking number sent by email."),
76+
],
77+
)
78+
yield table_name
79+
finally:
80+
conn.close()
81+
82+
83+
async def test_postgres_ingest_end_to_end(postgres_table):
84+
"""Full round trip: Postgres → Moss index → query → delete."""
85+
table_name = postgres_table
86+
client = MossClient(PROJECT_ID, PROJECT_KEY)
87+
88+
# Unique index name per run so concurrent runs don't collide.
89+
index_name = f"moss-postgres-e2e-{uuid.uuid4().hex[:8]}"
90+
91+
try:
92+
connector = PostgresConnector(
93+
dsn=POSTGRES_DSN,
94+
query=f"SELECT id, title, body FROM {table_name}",
95+
mapper=lambda r: DocumentInfo(
96+
id=str(r["id"]),
97+
text=r["body"],
98+
metadata={"title": r["title"]},
99+
),
100+
)
101+
102+
result = await ingest(connector, PROJECT_ID, PROJECT_KEY, index_name=index_name)
103+
assert result is not None
104+
assert result.doc_count == 5
105+
106+
# Query the live index. "refund" should pull back article 1.
107+
await client.load_index(index_name)
108+
result = await client.query(
109+
index_name, "how long do refunds take", QueryOptions(top_k=3)
110+
)
111+
112+
assert result.docs, "expected at least one document in the search result"
113+
top_ids = [d.id for d in result.docs]
114+
assert "1" in top_ids, f"refund-policy doc not in top 3: {top_ids}"
115+
116+
# Check the metadata survived the round trip.
117+
refund_doc = next(d for d in result.docs if d.id == "1")
118+
assert refund_doc.metadata is not None
119+
assert refund_doc.metadata.get("title") == "Refund policy"
120+
121+
finally:
122+
# Always try to clean up, even if an assertion above failed.
123+
try:
124+
await client.delete_index(index_name)
125+
except Exception as exc: # pragma: no cover, best-effort cleanup
126+
print(f"warning: failed to delete test index {index_name}: {exc}")

0 commit comments

Comments
 (0)