Skip to content

Commit d8ea5e6

Browse files
committed
feat: implement view support
1 parent e15afe0 commit d8ea5e6

File tree

12 files changed

+385
-30
lines changed

12 files changed

+385
-30
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "sqlx-gen"
3-
version = "0.1.0"
3+
version = "0.2.0"
44
edition = "2021"
55
description = "Generate Rust structs from database schema introspection"
66
license = "MIT"

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Generate Rust structs from your database schema — with correct types, derives, and `sqlx::FromRow` annotations.
44

5-
Supports **PostgreSQL**, **MySQL**, and **SQLite**. Introspects tables, enums, composite types, and domains.
5+
Supports **PostgreSQL**, **MySQL**, and **SQLite**. Introspects tables, views, enums, composite types, and domains.
66

77
[![Crates.io](https://img.shields.io/crates/v/sqlx-gen.svg)](https://crates.io/crates/sqlx-gen)
88
[![docs.rs](https://docs.rs/sqlx-gen/badge.svg)](https://docs.rs/sqlx-gen)
@@ -19,6 +19,7 @@ Supports **PostgreSQL**, **MySQL**, and **SQLite**. Introspects tables, enums, c
1919
- Correct nullable handling (`Option<T>`)
2020
- Custom derives (`--derives Serialize,Deserialize`)
2121
- Type overrides (`--type-overrides jsonb=MyType`)
22+
- SQL views support (`--views`)
2223
- Table filtering (`--tables users,orders`)
2324
- Single-file or multi-file output
2425
- Dry-run mode (preview on stdout)
@@ -51,6 +52,11 @@ sqlx-gen -u sqlite:./local.db -o src/models
5152
sqlx-gen -u postgres://... --derives Serialize,Deserialize -o src/models
5253
```
5354

55+
### Include SQL views
56+
```sh
57+
sqlx-gen -u postgres://... --views -o src/models
58+
```
59+
5460
### Dry run (preview without writing)
5561
```sh
5662
sqlx-gen -u postgres://... --dry-run
@@ -66,6 +72,7 @@ sqlx-gen -u postgres://... --dry-run
6672
| `--derives` | | Additional derive macros (comma-separated) | none |
6773
| `--type-overrides` | | Type overrides `sql_type=RustType` (comma-separated) | none |
6874
| `--tables` | | Only generate these tables (comma-separated) | all |
75+
| `--views` | | Also generate structs for SQL views | false |
6976
| `--single-file` | | Write everything to a single `models.rs` | false |
7077
| `--dry-run` | | Print to stdout, don't write files | false |
7178

src/cli.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ pub struct Args {
3333
#[arg(long, value_delimiter = ',')]
3434
pub tables: Option<Vec<String>>,
3535

36+
/// Also generate structs for SQL views
37+
#[arg(long)]
38+
pub views: bool,
39+
3640
/// Print to stdout without writing files
3741
#[arg(long)]
3842
pub dry_run: bool,
@@ -85,6 +89,7 @@ mod tests {
8589
type_overrides: vec![],
8690
single_file: false,
8791
tables: None,
92+
views: false,
8893
dry_run: false,
8994
}
9095
}
@@ -98,6 +103,7 @@ mod tests {
98103
type_overrides: overrides.into_iter().map(|s| s.to_string()).collect(),
99104
single_file: false,
100105
tables: None,
106+
views: false,
101107
dry_run: false,
102108
}
103109
}

src/codegen/mod.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,21 @@ pub fn generate(
9494
});
9595
}
9696

97+
// Generate struct files for each view
98+
for view in &schema_info.views {
99+
let (tokens, imports) =
100+
struct_gen::generate_struct(view, db_kind, schema_info, extra_derives, type_overrides);
101+
let imports = filter_imports(&imports, single_file);
102+
let code = format_tokens_with_imports(&tokens, &imports);
103+
let module_name = normalize_module_name(&view.name);
104+
let origin = format!("View: {}.{}", view.schema_name, view.name);
105+
files.push(GeneratedFile {
106+
filename: format!("{}.rs", module_name),
107+
origin: Some(origin),
108+
code,
109+
});
110+
}
111+
97112
// Generate types file (enums, composites, domains)
98113
// Each item is formatted individually so we can insert blank lines between them.
99114
let mut types_blocks: Vec<String> = Vec::new();
@@ -680,4 +695,88 @@ mod tests {
680695
assert!(parse_result.is_ok(), "Failed to parse {}: {:?}", f.filename, parse_result.err());
681696
}
682697
}
698+
699+
// ========== generate() — views ==========
700+
701+
fn make_view(name: &str, columns: Vec<ColumnInfo>) -> TableInfo {
702+
TableInfo {
703+
schema_name: "public".to_string(),
704+
name: name.to_string(),
705+
columns,
706+
}
707+
}
708+
709+
#[test]
710+
fn test_generate_one_view() {
711+
let schema = SchemaInfo {
712+
views: vec![make_view("active_users", vec![make_col("id", "int4")])],
713+
..Default::default()
714+
};
715+
let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false);
716+
assert_eq!(files.len(), 1);
717+
assert_eq!(files[0].filename, "active_users.rs");
718+
}
719+
720+
#[test]
721+
fn test_generate_view_origin() {
722+
let schema = SchemaInfo {
723+
views: vec![make_view("active_users", vec![make_col("id", "int4")])],
724+
..Default::default()
725+
};
726+
let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false);
727+
assert_eq!(files[0].origin, Some("View: public.active_users".to_string()));
728+
}
729+
730+
#[test]
731+
fn test_generate_tables_and_views() {
732+
let schema = SchemaInfo {
733+
tables: vec![make_table("users", vec![make_col("id", "int4")])],
734+
views: vec![make_view("active_users", vec![make_col("id", "int4")])],
735+
..Default::default()
736+
};
737+
let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false);
738+
assert_eq!(files.len(), 2);
739+
}
740+
741+
#[test]
742+
fn test_generate_view_valid_rust() {
743+
let schema = SchemaInfo {
744+
views: vec![make_view("active_users", vec![
745+
make_col("id", "int4"),
746+
make_col("name", "text"),
747+
])],
748+
..Default::default()
749+
};
750+
let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false);
751+
let parse_result = syn::parse_file(&files[0].code);
752+
assert!(parse_result.is_ok(), "Failed to parse: {:?}", parse_result.err());
753+
}
754+
755+
#[test]
756+
fn test_generate_view_nullable_column() {
757+
let schema = SchemaInfo {
758+
views: vec![make_view("v", vec![ColumnInfo {
759+
name: "email".to_string(),
760+
data_type: "text".to_string(),
761+
udt_name: "text".to_string(),
762+
is_nullable: true,
763+
ordinal_position: 0,
764+
schema_name: "public".to_string(),
765+
}])],
766+
..Default::default()
767+
};
768+
let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), false);
769+
assert!(files[0].code.contains("Option<String>"));
770+
}
771+
772+
#[test]
773+
fn test_generate_view_single_file_mode() {
774+
let schema = SchemaInfo {
775+
tables: vec![make_table("users", vec![make_col("id", "int4")])],
776+
views: vec![make_view("active_users", vec![make_col("id", "int4")])],
777+
..Default::default()
778+
};
779+
let files = generate(&schema, DatabaseKind::Postgres, &[], &HashMap::new(), true);
780+
assert_eq!(files.len(), 2);
781+
}
683782
}

src/introspect/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ pub struct DomainInfo {
4747
#[derive(Debug, Clone, Default)]
4848
pub struct SchemaInfo {
4949
pub tables: Vec<TableInfo>,
50+
pub views: Vec<TableInfo>,
5051
pub enums: Vec<EnumInfo>,
5152
pub composite_types: Vec<CompositeTypeInfo>,
5253
pub domains: Vec<DomainInfo>,

src/introspect/mysql.rs

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,22 @@ use sqlx::MySqlPool;
33

44
use super::{ColumnInfo, EnumInfo, SchemaInfo, TableInfo};
55

6-
pub async fn introspect(pool: &MySqlPool, schemas: &[String]) -> Result<SchemaInfo> {
6+
pub async fn introspect(
7+
pool: &MySqlPool,
8+
schemas: &[String],
9+
include_views: bool,
10+
) -> Result<SchemaInfo> {
711
let tables = fetch_tables(pool, schemas).await?;
12+
let views = if include_views {
13+
fetch_views(pool, schemas).await?
14+
} else {
15+
Vec::new()
16+
};
817
let enums = extract_enums(&tables);
918

1019
Ok(SchemaInfo {
1120
tables,
21+
views,
1222
enums,
1323
composite_types: Vec::new(),
1424
domains: Vec::new(),
@@ -71,6 +81,61 @@ async fn fetch_tables(pool: &MySqlPool, schemas: &[String]) -> Result<Vec<TableI
7181
Ok(tables)
7282
}
7383

84+
async fn fetch_views(pool: &MySqlPool, schemas: &[String]) -> Result<Vec<TableInfo>> {
85+
let placeholders: Vec<String> = (0..schemas.len()).map(|_| "?".to_string()).collect();
86+
let query = format!(
87+
r#"
88+
SELECT
89+
c.TABLE_SCHEMA,
90+
c.TABLE_NAME,
91+
c.COLUMN_NAME,
92+
c.DATA_TYPE,
93+
c.COLUMN_TYPE,
94+
c.IS_NULLABLE,
95+
c.ORDINAL_POSITION
96+
FROM information_schema.COLUMNS c
97+
JOIN information_schema.TABLES t
98+
ON t.TABLE_SCHEMA = c.TABLE_SCHEMA
99+
AND t.TABLE_NAME = c.TABLE_NAME
100+
AND t.TABLE_TYPE = 'VIEW'
101+
WHERE c.TABLE_SCHEMA IN ({})
102+
ORDER BY c.TABLE_SCHEMA, c.TABLE_NAME, c.ORDINAL_POSITION
103+
"#,
104+
placeholders.join(",")
105+
);
106+
107+
let mut q = sqlx::query_as::<_, (String, String, String, String, String, String, u32)>(&query);
108+
for schema in schemas {
109+
q = q.bind(schema);
110+
}
111+
let rows = q.fetch_all(pool).await?;
112+
113+
let mut views: Vec<TableInfo> = Vec::new();
114+
let mut current_key: Option<(String, String)> = None;
115+
116+
for (schema, table, col_name, data_type, column_type, nullable, ordinal) in rows {
117+
let key = (schema.clone(), table.clone());
118+
if current_key.as_ref() != Some(&key) {
119+
current_key = Some(key);
120+
views.push(TableInfo {
121+
schema_name: schema.clone(),
122+
name: table.clone(),
123+
columns: Vec::new(),
124+
});
125+
}
126+
views.last_mut().unwrap().columns.push(ColumnInfo {
127+
name: col_name,
128+
data_type,
129+
udt_name: column_type,
130+
is_nullable: nullable == "YES",
131+
ordinal_position: ordinal as i32,
132+
schema_name: schema,
133+
});
134+
}
135+
136+
Ok(views)
137+
}
138+
74139
/// Extract inline ENUMs from column types.
75140
/// MySQL ENUM('a','b','c') in COLUMN_TYPE gets extracted to an EnumInfo
76141
/// keyed by table_name + column_name.

src/introspect/postgres.rs

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,24 @@ use sqlx::PgPool;
33

44
use super::{ColumnInfo, CompositeTypeInfo, DomainInfo, EnumInfo, SchemaInfo, TableInfo};
55

6-
pub async fn introspect(pool: &PgPool, schemas: &[String]) -> Result<SchemaInfo> {
6+
pub async fn introspect(
7+
pool: &PgPool,
8+
schemas: &[String],
9+
include_views: bool,
10+
) -> Result<SchemaInfo> {
711
let tables = fetch_tables(pool, schemas).await?;
12+
let views = if include_views {
13+
fetch_views(pool, schemas).await?
14+
} else {
15+
Vec::new()
16+
};
817
let enums = fetch_enums(pool, schemas).await?;
918
let composite_types = fetch_composite_types(pool, schemas).await?;
1019
let domains = fetch_domains(pool, schemas).await?;
1120

1221
Ok(SchemaInfo {
1322
tables,
23+
views,
1424
enums,
1525
composite_types,
1626
domains,
@@ -67,6 +77,56 @@ async fn fetch_tables(pool: &PgPool, schemas: &[String]) -> Result<Vec<TableInfo
6777
Ok(tables)
6878
}
6979

80+
async fn fetch_views(pool: &PgPool, schemas: &[String]) -> Result<Vec<TableInfo>> {
81+
let rows = sqlx::query_as::<_, (String, String, String, String, String, String, i32)>(
82+
r#"
83+
SELECT
84+
c.table_schema,
85+
c.table_name,
86+
c.column_name,
87+
c.data_type,
88+
COALESCE(c.udt_name, c.data_type) as udt_name,
89+
c.is_nullable,
90+
c.ordinal_position
91+
FROM information_schema.columns c
92+
JOIN information_schema.tables t
93+
ON t.table_schema = c.table_schema
94+
AND t.table_name = c.table_name
95+
AND t.table_type = 'VIEW'
96+
WHERE c.table_schema = ANY($1)
97+
ORDER BY c.table_schema, c.table_name, c.ordinal_position
98+
"#,
99+
)
100+
.bind(schemas)
101+
.fetch_all(pool)
102+
.await?;
103+
104+
let mut views: Vec<TableInfo> = Vec::new();
105+
let mut current_key: Option<(String, String)> = None;
106+
107+
for (schema, table, col_name, data_type, udt_name, nullable, ordinal) in rows {
108+
let key = (schema.clone(), table.clone());
109+
if current_key.as_ref() != Some(&key) {
110+
current_key = Some(key);
111+
views.push(TableInfo {
112+
schema_name: schema.clone(),
113+
name: table.clone(),
114+
columns: Vec::new(),
115+
});
116+
}
117+
views.last_mut().unwrap().columns.push(ColumnInfo {
118+
name: col_name,
119+
data_type,
120+
udt_name,
121+
is_nullable: nullable == "YES",
122+
ordinal_position: ordinal,
123+
schema_name: schema,
124+
});
125+
}
126+
127+
Ok(views)
128+
}
129+
70130
async fn fetch_enums(pool: &PgPool, schemas: &[String]) -> Result<Vec<EnumInfo>> {
71131
let rows = sqlx::query_as::<_, (String, String, String)>(
72132
r#"

0 commit comments

Comments
 (0)