Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
401 changes: 397 additions & 4 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pqb/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ uuid = { version = "1", default-features = false, optional = true }

[dev-dependencies]
insta = { version = "1.46.1" }
pg_query = { version = "6.1.1" }

[lints]
workspace = true
8 changes: 7 additions & 1 deletion pqb/src/query/conflict.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,13 @@ pub(crate) fn write_on_conflict<W: SqlWriter>(w: &mut W, on_conflict: &OnConflic
if i > 0 {
w.push_str(", ");
}
write_expr(w, expr);
if matches!(expr, Expr::Column(_)) {
write_expr(w, expr);
} else {
w.push_str("(");
write_expr(w, expr);
w.push_str(")");
}
}
w.push_str(")");
}
Expand Down
2 changes: 1 addition & 1 deletion pqb/src/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use crate::writer::SqlWriter;

/// SQL value variants.
#[derive(Debug, Clone, PartialEq)]
#[expect(missing_docs)]
#[allow(missing_docs)]
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pub enum Value {
Bool(Option<bool>),
TinyInt(Option<i8>),
Expand Down
25 changes: 25 additions & 0 deletions pqb/tests/common.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright 2025 FastLabs Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

pub trait ValidateSql {
fn validate(self) -> Self;
}

impl ValidateSql for String {
#[track_caller]
fn validate(self) -> Self {
pg_query::parse(self.as_str()).unwrap();
self
}
}
17 changes: 13 additions & 4 deletions pqb/tests/create_table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.

mod common;

use insta::assert_snapshot;
use pqb::expr::Expr;
use pqb::index::CreateIndex;
use pqb::table::ColumnDef;
use pqb::table::ColumnType;
use pqb::table::CreateTable;

use crate::common::ValidateSql;

#[test]
fn create_table_basic() {
assert_snapshot!(
Expand All @@ -28,7 +32,8 @@ fn create_table_basic() {
.column(ColumnDef::new("email").text().not_null())
.column(ColumnDef::new("nickname").text().null())
.column(ColumnDef::new("created_at").timestamp_with_time_zone())
.to_sql(),
.to_sql()
.validate(),
@r#"CREATE TABLE "users" ( "id" bigint NOT NULL, "email" text NOT NULL, "nickname" text NULL, "created_at" timestamp with time zone )"#
);
}
Expand All @@ -42,7 +47,8 @@ fn create_table_if_not_exists_temporary() {
.table("cache")
.column(ColumnDef::new("key").text().not_null())
.column(ColumnDef::new("value").json_binary())
.to_sql(),
.to_sql()
.validate(),
@r#"CREATE TEMPORARY TABLE IF NOT EXISTS "cache" ( "key" text NOT NULL, "value" jsonb )"#
);
}
Expand All @@ -55,7 +61,8 @@ fn create_table_primary_key_index() {
.column(ColumnDef::new("id").int())
.column(ColumnDef::new("name").text())
.primary_key(CreateIndex::new().column("id"))
.to_sql(),
.to_sql()
.validate(),
@r#"CREATE TABLE "widgets" ( "id" integer, "name" text, PRIMARY KEY ("id") )"#
);
}
Expand All @@ -78,6 +85,7 @@ fn create_table_generated_column() {
.generated_as_virtual(Expr::column("sum").div(Expr::value(2))),
)
.to_sql(),
// .validate(), // TODO: Before PostgreSQL 18, STORED is the only supported kind and must be specified.
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@r#"CREATE TABLE "calc" ( "a" integer, "b" integer, "sum" integer GENERATED ALWAYS AS ("a" + "b") STORED, "avg" integer GENERATED ALWAYS AS ("sum" / 2) VIRTUAL )"#
);
}
Expand Down Expand Up @@ -116,7 +124,8 @@ fn create_table_all_column_types() {
.column(ColumnDef::new("col_jsonb").json_binary())
.column(ColumnDef::new("col_uuid").uuid())
.column(ColumnDef::new("col_int_array").array_of(ColumnType::Int))
.to_sql(),
.to_sql()
.validate(),
@r#"CREATE TABLE "all_types" ( "col_char" char(4), "col_varchar" varchar(10), "col_text" text, "col_bytea" bytea, "col_smallint" smallint, "col_int" integer, "col_bigint" bigint, "col_float" real, "col_double" double precision, "col_numeric" numeric(10, 2), "col_smallserial" smallserial, "col_serial" serial, "col_bigserial" bigserial, "col_int4range" int4range, "col_int8range" int8range, "col_numrange" numrange, "col_tsrange" tsrange, "col_tstzrange" tstzrange, "col_daterange" daterange, "col_datetime" timestamp without time zone, "col_timestamp" timestamp, "col_timestamptz" timestamp with time zone, "col_time" time, "col_date" date, "col_bool" bool, "col_json" json, "col_jsonb" jsonb, "col_uuid" uuid, "col_int_array" integer[] )"#
);
}
Expand Down
13 changes: 10 additions & 3 deletions pqb/tests/drop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,15 @@
// See the License for the specific language governing permissions and
// limitations under the License.

mod common;

use insta::assert_snapshot;
use pqb::index::DropIndex;
use pqb::schema::DropSchema;
use pqb::table::DropTable;

use crate::common::ValidateSql;

#[test]
fn drop_index_sql() {
assert_snapshot!(
Expand All @@ -25,7 +29,8 @@ fn drop_index_sql() {
.if_exists()
.concurrently()
.cascade()
.to_sql(),
.to_sql()
.validate(),
@r#"DROP INDEX CONCURRENTLY IF EXISTS "public"."idx_users_email" CASCADE"#
);
}
Expand All @@ -37,7 +42,8 @@ fn drop_table_sql() {
.tables([("public", "users"), ("public", "accounts")])
.if_exists()
.restrict()
.to_sql(),
.to_sql()
.validate(),
@r#"DROP TABLE IF EXISTS "public"."users", "public"."accounts" RESTRICT"#
);
}
Expand All @@ -49,7 +55,8 @@ fn drop_schema_sql() {
.schemas(["public", "analytics"])
.if_exists()
.cascade()
.to_sql(),
.to_sql()
.validate(),
@r#"DROP SCHEMA IF EXISTS "public", "analytics" CASCADE"#
);
}
16 changes: 12 additions & 4 deletions pqb/tests/explain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@
// See the License for the specific language governing permissions and
// limitations under the License.

mod common;

use insta::assert_snapshot;
use pqb::query::Explain;
use pqb::query::Select;

use crate::common::ValidateSql;

#[test]
fn explain_postgres_select_with_options() {
assert_snapshot!(
Expand All @@ -33,7 +37,8 @@ fn explain_postgres_select_with_options() {
.memory(true)
.format_json()
.statement(Select::new().column("character").from("character"))
.to_sql(),
.to_sql()
.validate(),
@r#"EXPLAIN (ANALYZE, VERBOSE 0, COSTS, SETTINGS 0, GENERIC_PLAN, BUFFERS, SERIALIZE TEXT, WAL, TIMING 0, SUMMARY, MEMORY, FORMAT JSON) SELECT "character" FROM "character""#
);
}
Expand All @@ -44,7 +49,8 @@ fn explain_postgres_serialize_text() {
Explain::new()
.serialize_text()
.statement(Select::new().column("character").from("character"))
.to_sql(),
.to_sql()
.validate(),
@r#"EXPLAIN (SERIALIZE TEXT) SELECT "character" FROM "character""#
);
}
Expand All @@ -55,7 +61,8 @@ fn explain_postgres_serialize_binary() {
Explain::new()
.serialize_binary()
.statement(Select::new().column("character").from("character"))
.to_sql(),
.to_sql()
.validate(),
@r#"EXPLAIN (SERIALIZE BINARY) SELECT "character" FROM "character""#
);
}
Expand All @@ -66,7 +73,8 @@ fn explain_postgres_serialize_none() {
Explain::new()
.serialize_none()
.statement(Select::new().column("character").from("character"))
.to_sql(),
.to_sql()
.validate(),
@r#"EXPLAIN (SERIALIZE NONE) SELECT "character" FROM "character""#
);
}
10 changes: 8 additions & 2 deletions pqb/tests/expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@
// See the License for the specific language governing permissions and
// limitations under the License.

mod common;

use insta::assert_snapshot;
use pqb::expr::Expr;
use pqb::query::Select;

use crate::common::ValidateSql;

#[test]
fn select_function() {
assert_snapshot!(
Expand All @@ -25,7 +29,8 @@ fn select_function() {
Expr::value(10),
Expr::value("[]"),
]))
.to_sql(),
.to_sql()
.validate(),
@"SELECT int8range(1, 10, '[]')"
);
}
Expand All @@ -47,7 +52,8 @@ fn select_range_ops() {
.and_where(left.clone().does_not_extend_right_of(right.clone()))
.and_where(left.clone().does_not_extend_left_of(right.clone()))
.and_where(left.adjacent_to(right))
.to_sql(),
.to_sql()
.validate(),
@r#"SELECT * FROM "ranges" WHERE "r1" @> "r2" AND "r1" <@ "r2" AND "r1" && "r2" AND "r1" << "r2" AND "r1" >> "r2" AND "r1" &< "r2" AND "r1" &> "r2" AND "r1" -|- "r2""#
);
}
40 changes: 28 additions & 12 deletions pqb/tests/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@
// See the License for the specific language governing permissions and
// limitations under the License.

mod common;

use insta::assert_snapshot;
use pqb::expr::Expr;
use pqb::index::CreateIndex;

use crate::common::ValidateSql;

#[test]
fn create_index_gist_with_options() {
assert_snapshot!(
Expand All @@ -25,7 +29,8 @@ fn create_index_gist_with_options() {
.gist()
.with_option("fillfactor", 80)
.with_option("buffering", "auto")
.to_sql(),
.to_sql()
.validate(),
@r#"CREATE INDEX ON "spatial" USING gist ("geom") WITH ("fillfactor" = 80, "buffering" = 'auto')"#
);
}
Expand All @@ -38,7 +43,8 @@ fn create_index_brin_with_options() {
.column("created_at")
.brin()
.with_options([("pages_per_range", Expr::value(32)), ("autosummarize", Expr::value(true))])
.to_sql(),
.to_sql()
.validate(),
@r#"CREATE INDEX ON "events" USING brin ("created_at") WITH ("pages_per_range" = 32, "autosummarize" = TRUE)"#
);
}
Expand All @@ -50,7 +56,8 @@ fn create_index_hash() {
.table("tokens")
.column("value")
.hash()
.to_sql(),
.to_sql()
.validate(),
@r#"CREATE INDEX ON "tokens" USING hash ("value")"#
);
}
Expand All @@ -62,7 +69,8 @@ fn create_index_named() {
.name("idx_tokens_value")
.table("tokens")
.column("value")
.to_sql(),
.to_sql()
.validate(),
@r#"CREATE INDEX "idx_tokens_value" ON "tokens" ("value")"#
);
}
Expand All @@ -76,7 +84,8 @@ fn create_index_if_not_exists_named() {
.column("created_at")
.brin()
.if_not_exists()
.to_sql(),
.to_sql()
.validate(),
@r#"CREATE INDEX IF NOT EXISTS "idx_events_created_at_brin" ON "events" USING brin ("created_at")"#
);
}
Expand All @@ -91,7 +100,8 @@ fn create_index_named_with_options() {
.gist()
.with_option("fillfactor", 90)
.with_option("buffering", "auto")
.to_sql(),
.to_sql()
.validate(),
@r#"CREATE INDEX "idx_spatial_geom_gist" ON "spatial" USING gist ("geom") WITH ("fillfactor" = 90, "buffering" = 'auto')"#
);
}
Expand All @@ -105,7 +115,8 @@ fn create_index_if_not_exists_custom_method() {
.column("value")
.if_not_exists()
.using("hnsw")
.to_sql(),
.to_sql()
.validate(),
@r#"CREATE INDEX IF NOT EXISTS "idx_tokens_value_hnsw" ON "tokens" USING hnsw ("value")"#
);
}
Expand All @@ -118,7 +129,8 @@ fn create_index_include_columns() {
.table("orders")
.column("customer_id")
.include_columns(["id", "created_at"])
.to_sql(),
.to_sql()
.validate(),
@r#"CREATE INDEX "idx_orders_customer" ON "orders" ("customer_id") INCLUDE ("id", "created_at")"#
);
}
Expand All @@ -131,7 +143,8 @@ fn create_index_partial() {
.table("sessions")
.column("user_id")
.index_where(Expr::column("expires_at").gt(Expr::current_timestamp()))
.to_sql(),
.to_sql()
.validate(),
@r#"CREATE INDEX "idx_sessions_active" ON "sessions" ("user_id") WHERE "expires_at" > CURRENT_TIMESTAMP"#
);
}
Expand All @@ -142,7 +155,8 @@ fn create_index_expression() {
CreateIndex::new()
.table("users")
.expr(Expr::function("lower", [Expr::column("email")]))
.to_sql(),
.to_sql()
.validate(),
@r#"CREATE INDEX ON "users" (lower("email"))"#
);
}
Expand All @@ -154,7 +168,8 @@ fn create_index_expression_with_column() {
.table("metrics")
.expr(Expr::column("value").add(Expr::value(1)))
.column("created_at")
.to_sql(),
.to_sql()
.validate(),
@r#"CREATE INDEX ON "metrics" (("value" + 1), "created_at")"#
);
}
Expand All @@ -168,7 +183,8 @@ fn create_index_concurrently() {
.column("customer_id")
.concurrently()
.if_not_exists()
.to_sql(),
.to_sql()
.validate(),
@r#"CREATE INDEX CONCURRENTLY IF NOT EXISTS "idx_orders_customer" ON "orders" ("customer_id")"#
);
}
Loading