Skip to content

Commit 59777c1

Browse files
committed
initial commit
1 parent 35049b6 commit 59777c1

13 files changed

Lines changed: 4720 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 3176 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[workspace]
2+
members = [
3+
"crates/gitbase-cli",
4+
"crates/gitbase-db",
5+
"crates/gitbase-pgwire",
6+
]
7+
resolver = "2"
8+
9+
[workspace.package]
10+
edition = "2021"
11+
license = "MIT"
12+
rust-version = "1.80"
13+
14+
[workspace.dependencies]
15+
anyhow = "1"
16+
async-trait = "0.1"
17+
clap = { version = "4", features = ["derive", "env"] }
18+
pgwire = "0.38"
19+
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio", "migrate", "macros"] }
20+
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "net"] }
21+
tracing = "0.1"
22+
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
23+
futures = "0.3"

crates/gitbase-cli/Cargo.toml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[package]
2+
name = "gitbase-cli"
3+
version = "0.1.0"
4+
edition.workspace = true
5+
6+
[[bin]]
7+
name = "gitbase"
8+
path = "src/main.rs"
9+
10+
[dependencies]
11+
anyhow.workspace = true
12+
clap.workspace = true
13+
gitbase-db = { path = "../gitbase-db" }
14+
gitbase-pgwire = { path = "../gitbase-pgwire" }
15+
tokio.workspace = true
16+
tracing.workspace = true
17+
tracing-subscriber.workspace = true

crates/gitbase-cli/src/main.rs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
use std::sync::Arc;
2+
3+
use anyhow::Result;
4+
use clap::Parser;
5+
use tracing_subscriber::EnvFilter;
6+
7+
#[derive(Parser)]
8+
#[command(name = "gitbase", about = "Git repository analytics via PostgreSQL wire protocol")]
9+
struct Cli {
10+
#[command(subcommand)]
11+
command: Commands,
12+
}
13+
14+
#[derive(clap::Subcommand)]
15+
enum Commands {
16+
/// Start the pgwire server
17+
Serve {
18+
/// Bind address for the pgwire listener
19+
#[arg(long, env = "GITBASE_BIND_ADDR", default_value = "0.0.0.0:5433")]
20+
bind: String,
21+
22+
/// PostgreSQL connection string
23+
#[arg(long, env = "DATABASE_URL")]
24+
database_url: String,
25+
26+
/// Maximum database connections
27+
#[arg(long, env = "GITBASE_DB_MAX_CONNECTIONS", default_value_t = 10)]
28+
max_connections: u32,
29+
},
30+
}
31+
32+
#[tokio::main]
33+
async fn main() -> Result<()> {
34+
tracing_subscriber::fmt()
35+
.with_env_filter(EnvFilter::from_default_env())
36+
.init();
37+
38+
let cli = Cli::parse();
39+
40+
match cli.command {
41+
Commands::Serve {
42+
bind,
43+
database_url,
44+
max_connections,
45+
} => {
46+
let pool = gitbase_db::connect(&database_url, max_connections).await?;
47+
gitbase_db::health_check(&pool).await?;
48+
tracing::info!("health check passed");
49+
50+
let factory = Arc::new(gitbase_pgwire::GitbaseServerFactory::new(pool));
51+
gitbase_pgwire::serve(&bind, factory).await?;
52+
}
53+
}
54+
55+
Ok(())
56+
}

crates/gitbase-db/Cargo.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
[package]
2+
name = "gitbase-db"
3+
version = "0.1.0"
4+
edition.workspace = true
5+
6+
[dependencies]
7+
anyhow.workspace = true
8+
sqlx.workspace = true
9+
tokio.workspace = true
10+
tracing.workspace = true

crates/gitbase-db/src/lib.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
use anyhow::Result;
2+
use sqlx::postgres::PgPoolOptions;
3+
use sqlx::PgPool;
4+
5+
/// Create a connection pool to PostgreSQL and run migrations.
6+
pub async fn connect(database_url: &str, max_connections: u32) -> Result<PgPool> {
7+
let pool = PgPoolOptions::new()
8+
.max_connections(max_connections)
9+
.connect(database_url)
10+
.await?;
11+
12+
tracing::info!("connected to PostgreSQL");
13+
14+
sqlx::migrate!("../../migrations").run(&pool).await?;
15+
tracing::info!("migrations applied");
16+
17+
Ok(pool)
18+
}
19+
20+
/// Simple health check – runs `SELECT 1`.
21+
pub async fn health_check(pool: &PgPool) -> Result<()> {
22+
sqlx::query_scalar::<_, i32>("SELECT 1")
23+
.fetch_one(pool)
24+
.await?;
25+
Ok(())
26+
}

crates/gitbase-pgwire/Cargo.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[package]
2+
name = "gitbase-pgwire"
3+
version = "0.1.0"
4+
edition.workspace = true
5+
6+
[dependencies]
7+
anyhow.workspace = true
8+
async-trait.workspace = true
9+
futures.workspace = true
10+
pgwire.workspace = true
11+
sqlx.workspace = true
12+
tokio.workspace = true
13+
tracing.workspace = true
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
use std::fmt::Debug;
2+
use std::sync::Arc;
3+
4+
use async_trait::async_trait;
5+
use futures::stream;
6+
use pgwire::api::auth::noop::NoopStartupHandler;
7+
use pgwire::api::query::SimpleQueryHandler;
8+
use pgwire::api::results::{DataRowEncoder, FieldFormat, FieldInfo, QueryResponse, Response, Tag};
9+
use pgwire::api::{ClientInfo, PgWireServerHandlers, Type};
10+
use pgwire::error::{PgWireError, PgWireResult};
11+
use pgwire::messages::{PgWireBackendMessage, PgWireFrontendMessage};
12+
use sqlx::postgres::PgRow;
13+
use sqlx::{Column, PgPool, Row, TypeInfo};
14+
15+
use futures::Sink;
16+
17+
/// Handles incoming PostgreSQL wire-protocol queries by forwarding them to a
18+
/// real PostgreSQL database through sqlx.
19+
pub struct GitbaseHandler {
20+
pool: PgPool,
21+
}
22+
23+
impl GitbaseHandler {
24+
pub fn new(pool: PgPool) -> Self {
25+
Self { pool }
26+
}
27+
}
28+
29+
#[async_trait]
30+
impl NoopStartupHandler for GitbaseHandler {
31+
async fn post_startup<C>(
32+
&self,
33+
client: &mut C,
34+
_message: PgWireFrontendMessage,
35+
) -> PgWireResult<()>
36+
where
37+
C: ClientInfo + Sink<PgWireBackendMessage> + Unpin + Send,
38+
C::Error: Debug,
39+
PgWireError: From<<C as Sink<PgWireBackendMessage>>::Error>,
40+
{
41+
tracing::info!(
42+
addr = %client.socket_addr(),
43+
"client connected"
44+
);
45+
Ok(())
46+
}
47+
}
48+
49+
#[async_trait]
50+
impl SimpleQueryHandler for GitbaseHandler {
51+
async fn do_query<C>(&self, _client: &mut C, query: &str) -> PgWireResult<Vec<Response>>
52+
where
53+
C: ClientInfo + Unpin + Send + Sync,
54+
{
55+
tracing::debug!(query, "forwarding query to PostgreSQL");
56+
57+
let trimmed = query.trim().to_uppercase();
58+
59+
// For SELECT queries, fetch rows and stream them back.
60+
if trimmed.starts_with("SELECT") {
61+
let rows: Vec<PgRow> = sqlx::query(query)
62+
.fetch_all(&self.pool)
63+
.await
64+
.map_err(|e| PgWireError::ApiError(Box::new(e)))?;
65+
66+
if rows.is_empty() {
67+
let schema = Arc::new(vec![]);
68+
let data_row_stream = stream::empty();
69+
return Ok(vec![Response::Query(QueryResponse::new(
70+
schema,
71+
data_row_stream,
72+
))]);
73+
}
74+
75+
// Build schema from the first row's columns.
76+
let columns = rows[0].columns();
77+
let field_infos: Vec<FieldInfo> = columns
78+
.iter()
79+
.map(|col| {
80+
let pg_type = sqlx_type_to_pgwire(col.type_info().name());
81+
FieldInfo::new(
82+
col.name().to_string(),
83+
None,
84+
None,
85+
pg_type,
86+
FieldFormat::Text,
87+
)
88+
})
89+
.collect();
90+
let schema = Arc::new(field_infos);
91+
92+
// Encode all rows.
93+
let schema_ref = schema.clone();
94+
let encoded_rows: Vec<_> = rows
95+
.iter()
96+
.map(|row| {
97+
let mut encoder = DataRowEncoder::new(schema_ref.clone());
98+
for (i, col) in row.columns().iter().enumerate() {
99+
let value: Option<String> = row.try_get(i).unwrap_or_else(|_| {
100+
// Fall back: try to get raw bytes and convert
101+
match col.type_info().name() {
102+
"INT4" | "INT8" | "INT2" => {
103+
row.try_get::<i64, _>(i).ok().map(|v| v.to_string())
104+
}
105+
"FLOAT4" | "FLOAT8" => {
106+
row.try_get::<f64, _>(i).ok().map(|v| v.to_string())
107+
}
108+
"BOOL" => row.try_get::<bool, _>(i).ok().map(|v| v.to_string()),
109+
_ => row.try_get::<String, _>(i).ok(),
110+
}
111+
});
112+
encoder.encode_field(&value).unwrap();
113+
}
114+
Ok(encoder.take_row())
115+
})
116+
.collect();
117+
118+
let data_row_stream = stream::iter(encoded_rows);
119+
Ok(vec![Response::Query(QueryResponse::new(
120+
schema,
121+
data_row_stream,
122+
))])
123+
} else {
124+
// Non-SELECT: execute and return affected rows.
125+
let result = sqlx::query(query)
126+
.execute(&self.pool)
127+
.await
128+
.map_err(|e| PgWireError::ApiError(Box::new(e)))?;
129+
Ok(vec![Response::Execution(
130+
Tag::new("OK").with_rows(result.rows_affected() as usize),
131+
)])
132+
}
133+
}
134+
}
135+
136+
/// Maps sqlx type names to pgwire Type constants.
137+
fn sqlx_type_to_pgwire(type_name: &str) -> Type {
138+
match type_name {
139+
"INT2" => Type::INT2,
140+
"INT4" => Type::INT4,
141+
"INT8" => Type::INT8,
142+
"FLOAT4" => Type::FLOAT4,
143+
"FLOAT8" => Type::FLOAT8,
144+
"BOOL" => Type::BOOL,
145+
"TEXT" => Type::TEXT,
146+
"VARCHAR" => Type::VARCHAR,
147+
"TIMESTAMP" => Type::TIMESTAMP,
148+
"TIMESTAMPTZ" => Type::TIMESTAMPTZ,
149+
"DATE" => Type::DATE,
150+
"UUID" => Type::UUID,
151+
"JSONB" | "JSON" => Type::JSONB,
152+
_ => Type::TEXT,
153+
}
154+
}
155+
156+
/// Factory used by pgwire to obtain handler instances per connection.
157+
pub struct GitbaseServerFactory {
158+
handler: Arc<GitbaseHandler>,
159+
}
160+
161+
impl GitbaseServerFactory {
162+
pub fn new(pool: PgPool) -> Self {
163+
Self {
164+
handler: Arc::new(GitbaseHandler::new(pool)),
165+
}
166+
}
167+
}
168+
169+
impl PgWireServerHandlers for GitbaseServerFactory {
170+
fn simple_query_handler(&self) -> Arc<impl SimpleQueryHandler> {
171+
self.handler.clone()
172+
}
173+
174+
fn startup_handler(&self) -> Arc<impl pgwire::api::auth::StartupHandler> {
175+
self.handler.clone()
176+
}
177+
}

crates/gitbase-pgwire/src/lib.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
mod handler;
2+
3+
pub use handler::GitbaseServerFactory;
4+
5+
use std::sync::Arc;
6+
use anyhow::Result;
7+
use tokio::net::TcpListener;
8+
use pgwire::tokio::process_socket;
9+
10+
/// Start the pgwire-compatible TCP server.
11+
pub async fn serve(bind_addr: &str, factory: Arc<GitbaseServerFactory>) -> Result<()> {
12+
let listener = TcpListener::bind(bind_addr).await?;
13+
tracing::info!("pgwire server listening on {bind_addr}");
14+
15+
loop {
16+
let (socket, addr) = listener.accept().await?;
17+
tracing::debug!("new connection from {addr}");
18+
let factory = factory.clone();
19+
tokio::spawn(async move {
20+
if let Err(e) = process_socket(socket, None, factory).await {
21+
tracing::error!("connection error from {addr}: {e}");
22+
}
23+
});
24+
}
25+
}

docker/docker-compose.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
services:
2+
postgres:
3+
image: postgres:16
4+
ports:
5+
- "5432:5432"
6+
environment:
7+
POSTGRES_USER: gitbase
8+
POSTGRES_PASSWORD: gitbase
9+
POSTGRES_DB: gitbase
10+
volumes:
11+
- pgdata:/var/lib/postgresql/data
12+
healthcheck:
13+
test: ["CMD-SHELL", "pg_isready -U gitbase"]
14+
interval: 5s
15+
timeout: 3s
16+
retries: 5
17+
18+
volumes:
19+
pgdata:

0 commit comments

Comments
 (0)