Skip to content

Commit 85a1dc3

Browse files
committed
feat: dblint
1 parent 6a7172b commit 85a1dc3

40 files changed

+1330
-173
lines changed

Cargo.lock

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

crates/pgls_cli/src/commands/dblint.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::time::Instant;
33
use crate::cli_options::CliOptions;
44
use crate::reporter::Report;
55
use crate::{CliDiagnostic, CliSession, VcsIntegration};
6+
use pgls_analyse::RuleCategoriesBuilder;
67
use pgls_configuration::PartialConfiguration;
78
use pgls_diagnostics::Error;
89
use pgls_workspace::features::diagnostics::{PullDatabaseDiagnosticsParams, PullDiagnosticsResult};
@@ -24,10 +25,17 @@ pub fn dblint(
2425

2526
let start = Instant::now();
2627

28+
let params = PullDatabaseDiagnosticsParams {
29+
categories: RuleCategoriesBuilder::default().all().build(),
30+
max_diagnostics,
31+
only: Vec::new(), // Uses configuration settings
32+
skip: Vec::new(), // Uses configuration settings
33+
};
34+
2735
let PullDiagnosticsResult {
2836
diagnostics,
2937
skipped_diagnostics,
30-
} = workspace.pull_db_diagnostics(PullDatabaseDiagnosticsParams { max_diagnostics })?;
38+
} = workspace.pull_db_diagnostics(params)?;
3139

3240
let report = Report::new(
3341
diagnostics.into_iter().map(Error::from).collect(),

crates/pgls_cli/src/commands/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ pub enum PgLSCommand {
2424
#[bpaf(command)]
2525
Version(#[bpaf(external(cli_options), hide_usage)] CliOptions),
2626

27-
/// Runs everything to the requested files.
27+
/// Lints your database schema.
2828
#[bpaf(command)]
2929
Dblint {
3030
#[bpaf(external(partial_configuration), hide_usage, optional)]
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
use assert_cmd::Command;
2+
use std::process::ExitStatus;
3+
4+
const BIN: &str = "postgres-language-server";
5+
6+
/// Get database URL from environment or use default docker-compose URL
7+
fn get_database_url() -> Option<String> {
8+
std::env::var("DATABASE_URL")
9+
.ok()
10+
.or_else(|| Some("postgres://postgres:postgres@127.0.0.1:5432/postgres".to_string()))
11+
}
12+
13+
/// Execute SQL against the database
14+
fn execute_sql(sql: &str) -> bool {
15+
let Some(url) = get_database_url() else {
16+
return false;
17+
};
18+
19+
std::process::Command::new("psql")
20+
.arg(&url)
21+
.arg("-c")
22+
.arg(sql)
23+
.output()
24+
.map(|o| o.status.success())
25+
.unwrap_or(false)
26+
}
27+
28+
/// Setup test schema with known issues for splinter to detect
29+
fn setup_test_schema() {
30+
// Create a table without a primary key (triggers no_primary_key rule)
31+
execute_sql("DROP TABLE IF EXISTS dblint_test_no_pk CASCADE");
32+
execute_sql("CREATE TABLE dblint_test_no_pk (id int, name text)");
33+
}
34+
35+
/// Cleanup test schema
36+
fn cleanup_test_schema() {
37+
execute_sql("DROP TABLE IF EXISTS dblint_test_no_pk CASCADE");
38+
}
39+
40+
#[test]
41+
#[cfg_attr(
42+
target_os = "windows",
43+
ignore = "snapshot expectations only validated on unix-like platforms"
44+
)]
45+
fn dblint_runs_without_errors() {
46+
let output = run_dblint(&[]);
47+
assert!(
48+
output.contains("Command completed"),
49+
"Expected successful completion, got: {output}",
50+
);
51+
}
52+
53+
#[test]
54+
#[cfg_attr(
55+
target_os = "windows",
56+
ignore = "snapshot expectations only validated on unix-like platforms"
57+
)]
58+
fn dblint_detects_no_primary_key() {
59+
// Setup: create table without primary key
60+
setup_test_schema();
61+
62+
// Run dblint
63+
let output = run_dblint(&[]);
64+
65+
// Cleanup
66+
cleanup_test_schema();
67+
68+
// Should detect the no_primary_key issue
69+
assert!(
70+
output.contains("noPrimaryKey") || output.contains("primary key"),
71+
"Expected to detect missing primary key issue, got: {output}",
72+
);
73+
}
74+
75+
#[test]
76+
#[cfg_attr(
77+
target_os = "windows",
78+
ignore = "snapshot expectations only validated on unix-like platforms"
79+
)]
80+
fn dblint_fails_without_database() {
81+
// Test that dblint fails gracefully when no database is configured
82+
let mut cmd = Command::cargo_bin(BIN).expect("binary not built");
83+
let output = cmd
84+
.args(["dblint", "--disable-db", "--log-level", "none"])
85+
.output()
86+
.expect("failed to run CLI");
87+
88+
let stdout = String::from_utf8_lossy(&output.stdout);
89+
let stderr = String::from_utf8_lossy(&output.stderr);
90+
91+
// Should complete (possibly with warning about no database)
92+
assert!(
93+
output.status.success()
94+
|| stderr.contains("database")
95+
|| stdout.contains("Command completed"),
96+
"Expected graceful handling without database, got stdout: {stdout}, stderr: {stderr}",
97+
);
98+
}
99+
100+
fn run_dblint(args: &[&str]) -> String {
101+
let url = get_database_url().expect("database URL required");
102+
103+
let mut cmd = Command::cargo_bin(BIN).expect("binary not built");
104+
let mut full_args = vec!["dblint", "--connection-string", &url, "--log-level", "none"];
105+
full_args.extend_from_slice(args);
106+
107+
let output = cmd.args(full_args).output().expect("failed to run CLI");
108+
109+
normalize_output(
110+
output.status,
111+
&String::from_utf8_lossy(&output.stdout),
112+
&String::from_utf8_lossy(&output.stderr),
113+
)
114+
}
115+
116+
fn normalize_output(status: ExitStatus, stdout: &str, stderr: &str) -> String {
117+
let normalized_stdout = normalize_durations(stdout);
118+
let status_label = if status.success() {
119+
"success"
120+
} else {
121+
"failure"
122+
};
123+
format!(
124+
"status: {status_label}\nstdout:\n{}\nstderr:\n{}\n",
125+
normalized_stdout.trim_end(),
126+
stderr.trim_end()
127+
)
128+
}
129+
130+
fn normalize_durations(input: &str) -> String {
131+
let mut content = input.to_owned();
132+
133+
let mut search_start = 0;
134+
while let Some(relative) = content[search_start..].find(" in ") {
135+
let start = search_start + relative + 4;
136+
if let Some(end_rel) = content[start..].find('.') {
137+
let end = start + end_rel;
138+
if content[start..end].chars().any(|c| c.is_ascii_digit()) {
139+
content.replace_range(start..end, "<duration>");
140+
search_start = start + "<duration>".len() + 1;
141+
continue;
142+
}
143+
search_start = end + 1;
144+
} else {
145+
break;
146+
}
147+
}
148+
149+
content
150+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
source: crates/pgls_cli/tests/assert_dblint.rs
3+
assertion_line: 126
4+
expression: normalized
5+
---
6+
status: failure
7+
stdout:
8+
9+
stderr:
10+
Error: couldn't parse `summary`: value "summary" is not valid for the --reporter argument

crates/pgls_configuration/src/lib.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ pub use rules::{
4242
RuleWithFixOptions, RuleWithOptions,
4343
};
4444
use serde::{Deserialize, Serialize};
45+
use splinter::{
46+
PartialSplinterConfiguration, SplinterConfiguration, partial_splinter_configuration,
47+
};
4548
pub use typecheck::{
4649
PartialTypecheckConfiguration, TypecheckConfiguration, partial_typecheck_configuration,
4750
};
@@ -86,6 +89,10 @@ pub struct Configuration {
8689
#[partial(type, bpaf(external(partial_linter_configuration), optional))]
8790
pub linter: LinterConfiguration,
8891

92+
/// The configuration for splinter
93+
#[partial(type, bpaf(external(partial_splinter_configuration), optional))]
94+
pub splinter: SplinterConfiguration,
95+
8996
/// The configuration for type checking
9097
#[partial(type, bpaf(external(partial_typecheck_configuration), optional))]
9198
pub typecheck: TypecheckConfiguration,
@@ -127,6 +134,10 @@ impl PartialConfiguration {
127134
}),
128135
..Default::default()
129136
}),
137+
splinter: Some(PartialSplinterConfiguration {
138+
enabled: Some(true),
139+
..Default::default()
140+
}),
130141
typecheck: Some(PartialTypecheckConfiguration {
131142
..Default::default()
132143
}),

crates/pgls_configuration/src/linter/rules.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ impl std::str::FromStr for RuleGroup {
4646
}
4747
#[derive(Clone, Debug, Default, Deserialize, Eq, Merge, PartialEq, Serialize)]
4848
#[cfg_attr(feature = "schema", derive(JsonSchema))]
49+
#[cfg_attr(feature = "schema", schemars(rename = "LinterRules"))]
4950
#[serde(rename_all = "camelCase", deny_unknown_fields)]
5051
pub struct Rules {
5152
#[doc = r" It enables the lint rules recommended by Postgres Language Server. `true` by default."]

crates/pgls_configuration/src/splinter/mod.rs

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
33
#![doc = r" Generated file, do not edit by hand, see `xtask/codegen`"]
44
mod rules;
5-
use biome_deserialize::StringSet;
65
use biome_deserialize_macros::{Merge, Partial};
76
use bpaf::Bpaf;
87
pub use rules::*;
@@ -18,12 +17,6 @@ pub struct SplinterConfiguration {
1817
#[doc = r" List of rules"]
1918
#[partial(bpaf(pure(Default::default()), optional, hide))]
2019
pub rules: Rules,
21-
#[doc = r" A list of Unix shell style patterns. The linter will ignore files/folders that will match these patterns."]
22-
#[partial(bpaf(hide))]
23-
pub ignore: StringSet,
24-
#[doc = r" A list of Unix shell style patterns. The linter will include files/folders that will match these patterns."]
25-
#[partial(bpaf(hide))]
26-
pub include: StringSet,
2720
}
2821
impl SplinterConfiguration {
2922
pub const fn is_disabled(&self) -> bool {
@@ -35,8 +28,6 @@ impl Default for SplinterConfiguration {
3528
Self {
3629
enabled: true,
3730
rules: Default::default(),
38-
ignore: Default::default(),
39-
include: Default::default(),
4031
}
4132
}
4233
}

0 commit comments

Comments
 (0)