fix: PG cell-edit serialization + recent-connections startup race (#6)
This commit was merged in pull request #6.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gridline-desktop",
|
||||
"private": true,
|
||||
"version": "0.7.11",
|
||||
"version": "0.7.12",
|
||||
"description": "An open-source, high-performance database GUI client for PostgreSQL and beyond",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+2
-1
@@ -1783,8 +1783,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridline"
|
||||
version = "0.7.10"
|
||||
version = "0.7.11"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"deadpool-postgres",
|
||||
"indexmap 2.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gridline"
|
||||
version = "0.7.11"
|
||||
version = "0.7.12"
|
||||
description = "An open-source, high-performance database GUI client for PostgreSQL and beyond"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -31,6 +31,7 @@ tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1"] }
|
||||
bytes = "1"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "mysql", "tls-rustls", "json"] }
|
||||
redis = { version = "0.27", features = ["tokio-comp"] }
|
||||
# async-ssh2 is not available at v0.4; using synchronous ssh2 via tokio::task::spawn_blocking per plan's fallback
|
||||
|
||||
@@ -8,10 +8,12 @@ use crate::models::db_viewer::{
|
||||
Change, ColumnInfo, ConstraintInfo, EnumInfo, ExtensionInfo, FunctionInfo, IndexInfo,
|
||||
QueryResult, SequenceInfo, TableInfo, TriggerInfo,
|
||||
};
|
||||
use bytes::BytesMut;
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use sqlx::Row;
|
||||
use tauri::State;
|
||||
use tokio_postgres::types::ToSql;
|
||||
use tokio_postgres::types::{Format, IsNull, ToSql, Type};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper functions
|
||||
@@ -585,26 +587,75 @@ pub fn build_pg_insert_sql(
|
||||
)
|
||||
}
|
||||
|
||||
/// A value bound to a PostgreSQL parameter in TEXT wire format, accepting any
|
||||
/// parameter type.
|
||||
///
|
||||
/// The frontend ships edited values as plain strings (and PK locators as
|
||||
/// numbers/booleans), but binding a native `String` fails for every non-text
|
||||
/// column type — `String::accepts` only matches `varchar`/`text`/`bpchar`/
|
||||
/// `name` — with `error serializing parameter N: cannot convert between the
|
||||
/// Rust type ... and the Postgres type ...`. Binding a native `i64` is also
|
||||
/// wrong because `i64::accepts` only matches `int8`.
|
||||
///
|
||||
/// `PgText` accepts *any* parameter type and writes the value as UTF-8 text in
|
||||
/// text wire format, so PostgreSQL itself parses the literal into the target
|
||||
/// column type. This works uniformly for `integer`, `bigint`, `numeric`,
|
||||
/// `boolean`, `uuid`, `json`/`jsonb`, timestamps, enums, etc. — exactly what
|
||||
/// PostgreSQL does for an `'<literal>'` in a query.
|
||||
#[derive(Debug, Clone)]
|
||||
struct PgText(String);
|
||||
|
||||
impl From<&str> for PgText {
|
||||
fn from(s: &str) -> Self {
|
||||
PgText(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl ToSql for PgText {
|
||||
fn to_sql(
|
||||
&self,
|
||||
_ty: &Type,
|
||||
out: &mut BytesMut,
|
||||
) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
|
||||
out.extend_from_slice(self.0.as_bytes());
|
||||
Ok(IsNull::No)
|
||||
}
|
||||
|
||||
fn accepts(_ty: &Type) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn encode_format(&self, _ty: &Type) -> Format {
|
||||
Format::Text
|
||||
}
|
||||
|
||||
fn to_sql_checked(
|
||||
&self,
|
||||
ty: &Type,
|
||||
out: &mut BytesMut,
|
||||
) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
|
||||
self.to_sql(ty, out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a JSON value into a boxed PostgreSQL-bindable value.
|
||||
///
|
||||
/// Maps common JSON types to `tokio_postgres::types::ToSql` implementors.
|
||||
/// Unknown/complex types are stringified as a fallback.
|
||||
/// Every non-null value is bound as a `PgText` (text wire format) so the server
|
||||
/// parses it into the target column type; SQL `NULL` is bound as `None`. This
|
||||
/// is deliberately type-agnostic — the caller already knows the column type
|
||||
/// through the SQL context (e.g. `SET "col" = $1` lets PostgreSQL infer `$1`
|
||||
/// as `col`'s type).
|
||||
fn pg_box_value(v: &serde_json::Value) -> Box<dyn ToSql + Send + Sync> {
|
||||
match v {
|
||||
serde_json::Value::Null => Box::new(Option::<String>::None),
|
||||
serde_json::Value::Bool(b) => Box::new(*b),
|
||||
serde_json::Value::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
Box::new(i)
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
Box::new(f)
|
||||
} else {
|
||||
Box::new(n.to_string())
|
||||
}
|
||||
}
|
||||
serde_json::Value::String(s) => Box::new(s.clone()),
|
||||
other => Box::new(other.to_string()),
|
||||
if v.is_null() {
|
||||
return Box::new(Option::<PgText>::None);
|
||||
}
|
||||
let text = match v {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Bool(b) => b.to_string(),
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
other => other.to_string(),
|
||||
};
|
||||
Box::new(PgText::from(text.as_str()))
|
||||
}
|
||||
|
||||
/// Convert a JSON value into a `rusqlite::types::Value` for SQLite binding.
|
||||
@@ -3320,6 +3371,78 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// PgText: universal text-format parameter binding (serialization fix)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pg_text_accepts_any_postgres_type() {
|
||||
// String::accepts only matches text-ish types, so a plain String can't
|
||||
// bind to an integer/boolean/uuid/jsonb column. PgText must accept all
|
||||
// of these (the server parses the text into the column's own type).
|
||||
for ty in [
|
||||
Type::TEXT,
|
||||
Type::VARCHAR,
|
||||
Type::INT4,
|
||||
Type::INT8,
|
||||
Type::FLOAT8,
|
||||
Type::NUMERIC,
|
||||
Type::BOOL,
|
||||
Type::UUID,
|
||||
Type::JSON,
|
||||
Type::JSONB,
|
||||
Type::TIMESTAMP,
|
||||
Type::TIMESTAMPTZ,
|
||||
Type::DATE,
|
||||
Type::TIME,
|
||||
] {
|
||||
assert!(
|
||||
PgText::accepts(&ty),
|
||||
"PgText must accept {:?}",
|
||||
ty
|
||||
);
|
||||
}
|
||||
// Sanity: a native String must NOT accept a non-text type (proving the
|
||||
// root cause this fix addresses).
|
||||
assert!(!<&str as ToSql>::accepts(&Type::INT4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_text_serializes_as_utf8_text() {
|
||||
use bytes::BytesMut;
|
||||
use tokio_postgres::types::IsNull;
|
||||
|
||||
let val = pg_box_value(&serde_json::json!("42"));
|
||||
let mut out = BytesMut::new();
|
||||
let isnull = val.to_sql_checked(&Type::INT4, &mut out).unwrap();
|
||||
assert!(matches!(isnull, IsNull::No));
|
||||
assert_eq!(&out[..], b"42");
|
||||
// Text wire format: the bytes are raw UTF-8, not binary-encoded int.
|
||||
assert!(matches!(val.encode_format(&Type::INT4), Format::Text));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_text_boxes_null_to_sql_null() {
|
||||
use bytes::BytesMut;
|
||||
use tokio_postgres::types::IsNull;
|
||||
|
||||
let val = pg_box_value(&serde_json::Value::Null);
|
||||
let mut out = BytesMut::new();
|
||||
assert!(matches!(
|
||||
val.to_sql_checked(&Type::TEXT, &mut out).unwrap(),
|
||||
IsNull::Yes
|
||||
));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pg_text_stringifies_booleans_and_numbers() {
|
||||
let b = pg_box_value(&serde_json::json!(true));
|
||||
assert!(matches!(b.encode_format(&Type::BOOL), Format::Text));
|
||||
let n = pg_box_value(&serde_json::json!(5));
|
||||
assert!(matches!(n.encode_format(&Type::INT4), Format::Text));
|
||||
}
|
||||
|
||||
async fn fresh_pool_manager() -> tokio::sync::Mutex<ConnectionPoolManager> {
|
||||
tokio::sync::Mutex::new(ConnectionPoolManager::new())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Gridline",
|
||||
"version": "0.7.11",
|
||||
"version": "0.7.12",
|
||||
"identifier": "com.adrianbonpin.gridline",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import tauriConf from "../../src-tauri/tauri.conf.json";
|
||||
|
||||
describe("tauri bundle config (v0.7.11)", () => {
|
||||
describe("tauri bundle config (v0.7.12)", () => {
|
||||
it("declares bundled pg_tools resources", () => {
|
||||
expect(tauriConf.bundle.resources).toContain("resources/pg_tools/*");
|
||||
});
|
||||
it("version is 0.7.11", () => {
|
||||
expect(tauriConf.version).toBe("0.7.11");
|
||||
it("version is 0.7.12", () => {
|
||||
expect(tauriConf.version).toBe("0.7.12");
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { describe, it, expect } from "vitest";
|
||||
import agents from "../../../AGENTS.md?raw";
|
||||
import readme from "../../../README.md?raw";
|
||||
|
||||
describe("v0.7.11 docs coverage", () => {
|
||||
describe("v0.7.12 docs coverage", () => {
|
||||
it("AGENTS.md marks inline cell editing complete", () => {
|
||||
expect(agents).toContain("Inline cell editing");
|
||||
expect(agents).toMatch(/Inline cell editing \| ✅/);
|
||||
@@ -24,8 +24,8 @@ describe("v0.7.11 docs coverage", () => {
|
||||
expect(agents).toMatch(/Connection status indicator on cards \| ✅/);
|
||||
expect(agents).toMatch(/Move-to-folder bulk action \| ✅/);
|
||||
});
|
||||
it("README declares v0.7.11", () => {
|
||||
expect(readme).toContain("0.7.11");
|
||||
it("README declares v0.7.12", () => {
|
||||
expect(readme).toContain("0.7.12");
|
||||
});
|
||||
it("AGENTS.md marks schema CRUD complete", () => {
|
||||
expect(agents).toMatch(/Schema CRUD \| ✅/);
|
||||
@@ -64,9 +64,9 @@ describe("v0.7.11 docs coverage", () => {
|
||||
expect(agents).toMatch(/Cancel long-running queries \| ✅/);
|
||||
expect(agents).toMatch(/Settings export\/import \| ✅/);
|
||||
});
|
||||
it("README links to v0.7.11 assets in both download tables", () => {
|
||||
expect(readme).toContain("releases/download/v0.7.11/");
|
||||
expect(readme).toContain("Gridline_0.7.11_aarch64.dmg");
|
||||
expect(readme).toContain("Gridline-0.7.11-1.x86_64.rpm");
|
||||
it("README links to v0.7.12 assets in both download tables", () => {
|
||||
expect(readme).toContain("releases/download/v0.7.12/");
|
||||
expect(readme).toContain("Gridline_0.7.12_aarch64.dmg");
|
||||
expect(readme).toContain("Gridline-0.7.12-1.x86_64.rpm");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
||||
import pkg from "../../package.json";
|
||||
|
||||
describe("version", () => {
|
||||
it("declares v0.7.11 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.11");
|
||||
it("declares v0.7.12 across the app shell", () => {
|
||||
expect(pkg.version).toBe("0.7.12");
|
||||
});
|
||||
});
|
||||
@@ -225,6 +225,33 @@ describe("favorites / recents / move-selection", () => {
|
||||
expect(commands.recordRecentConnection).toHaveBeenCalledWith("c1");
|
||||
});
|
||||
|
||||
it("loadAll refreshes recents once connections are known (startup race fix)", async () => {
|
||||
const conn = makeConn({ id: "c1", name: "Prod" });
|
||||
vi.spyOn(commands, "getConnections").mockResolvedValue([conn]);
|
||||
vi.spyOn(commands, "getFolders").mockResolvedValue([]);
|
||||
vi.spyOn(commands, "getTags").mockResolvedValue([]);
|
||||
vi.spyOn(commands, "getRecentConnections").mockResolvedValue([
|
||||
{ connection_id: "c1", opened_at: "2026-08-02T00:00:00Z" },
|
||||
]);
|
||||
|
||||
// Simulate the app-shell mount: loadAll fires and populates connections.
|
||||
await useConnectionStore.getState().loadAll();
|
||||
|
||||
// The recent strip must now show the connection that was opened previously.
|
||||
expect(commands.getRecentConnections).toHaveBeenCalledWith(8);
|
||||
expect(useConnectionStore.getState().recent).toEqual([conn]);
|
||||
});
|
||||
|
||||
it("loadRecent maps cached recent ids against the current connection list", async () => {
|
||||
const conn = makeConn({ id: "c1", name: "Prod" });
|
||||
useConnectionStore.setState({ connections: [conn] });
|
||||
vi.spyOn(commands, "getRecentConnections").mockResolvedValue([
|
||||
{ connection_id: "c1", opened_at: "2026-08-02T00:00:00Z" },
|
||||
]);
|
||||
await useConnectionStore.getState().loadRecent();
|
||||
expect(useConnectionStore.getState().recent).toEqual([conn]);
|
||||
});
|
||||
|
||||
describe("duplicateConnection", () => {
|
||||
it("copies fields with a '(copy)' name and favorite false", async () => {
|
||||
const src = makeConn({ id: "c1", name: "Prod", folder_id: "f1", tag_ids: ["t1"], favorite: true });
|
||||
|
||||
@@ -46,6 +46,9 @@ export const useConnectionStore = create<ConnectionState>((set, get) => ({
|
||||
set((s) => ({ connections: [...s.connections].sort((a, b) => Number(b.favorite) - Number(a.favorite)) }));
|
||||
// Also load tag order
|
||||
get().loadTagOrder();
|
||||
// Refresh recents once connections are known so the recent strip is not
|
||||
// computed against an empty list (avoids a startup race with loadRecent).
|
||||
get().loadRecent().catch(() => {});
|
||||
} catch (e) {
|
||||
set({ loading: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user