fw-nodes-postgres 0.0.1a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """PostgreSQL nodes for Flowire workflow automation."""
@@ -0,0 +1,23 @@
1
+ """Shared credential schema for PostgreSQL nodes."""
2
+
3
+ from typing import ClassVar
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class PostgresCredentialSchema(BaseModel):
9
+ """Credential schema for PostgreSQL database connections.
10
+
11
+ Shared across all PostgreSQL nodes in this package.
12
+ """
13
+
14
+ credential_name: ClassVar[str] = "PostgreSQL"
15
+ credential_description: ClassVar[str] = "PostgreSQL database connection details"
16
+ credential_icon: ClassVar[str | None] = "database"
17
+
18
+ host: str = Field(..., description="Database host (e.g., localhost or db.example.com)")
19
+ port: int = Field(default=5432, description="Database port")
20
+ database: str = Field(..., description="Database name")
21
+ user: str = Field(..., description="Database user")
22
+ password: str = Field(..., description="Database password")
23
+ ssl: bool = Field(default=False, description="Use SSL/TLS connection")
File without changes
@@ -0,0 +1,113 @@
1
+ """Structured DELETE node for PostgreSQL."""
2
+
3
+ from typing import Any
4
+
5
+ from flowire_sdk import BaseNode, BaseNodeOutput, InputField, NodeExecutionContext, NodeMetadata
6
+ from flowire_sdk.node_base import FieldOption
7
+ from pydantic import BaseModel, Field
8
+
9
+ from fw_nodes_postgres.credentials import PostgresCredentialSchema
10
+ from fw_nodes_postgres.utils.db import (
11
+ WhereCondition,
12
+ build_where_clause,
13
+ execute_query,
14
+ pg_field_options,
15
+ qualify_table,
16
+ sanitize_identifier,
17
+ serialize_rows,
18
+ )
19
+
20
+
21
+ class DeleteInput(BaseModel):
22
+ credential_id: str = InputField(..., description="PostgreSQL credential")
23
+ schema: str | None = InputField(
24
+ default=None,
25
+ description="PostgreSQL schema (defaults to search_path, usually 'public')",
26
+ dynamic_options=["credential_id"],
27
+ )
28
+ table: str = InputField(
29
+ ...,
30
+ description="Table to delete from",
31
+ dynamic_options=["credential_id", "schema"],
32
+ )
33
+ where: list[WhereCondition] = InputField(
34
+ ...,
35
+ min_length=1,
36
+ description="WHERE conditions (at least one required to prevent accidental full-table deletes)",
37
+ )
38
+ returning: list[str] = InputField(
39
+ default_factory=list,
40
+ description="Columns to return from deleted rows",
41
+ )
42
+ timeout: int = InputField(default=30, ge=1, le=300, description="Query timeout in seconds")
43
+
44
+
45
+ class DeleteOutput(BaseNodeOutput):
46
+ rows: list[dict[str, Any]] = Field(..., description="Returned rows (if RETURNING was specified)")
47
+ affected_count: int = Field(..., description="Number of rows deleted")
48
+ success: bool = Field(..., description="Whether the delete executed successfully")
49
+ raw_sql: str = Field(..., description="The generated SQL query (with $N placeholders)")
50
+
51
+
52
+ class DeleteNode(BaseNode):
53
+ """Delete rows from a PostgreSQL table."""
54
+
55
+ input_schema = DeleteInput
56
+ output_schema = DeleteOutput
57
+ credential_schema = PostgresCredentialSchema
58
+
59
+ metadata = NodeMetadata(
60
+ name="Postgres Delete",
61
+ description="Delete rows from a PostgreSQL table",
62
+ category="database",
63
+ icon="database",
64
+ color="#336791",
65
+ )
66
+
67
+ async def get_field_options(
68
+ self,
69
+ field_name: str,
70
+ credential_data: dict[str, Any] | None = None,
71
+ field_values: dict[str, Any] | None = None,
72
+ ) -> list[FieldOption]:
73
+ return await pg_field_options(field_name, credential_data, field_values)
74
+
75
+ async def execute_logic(
76
+ self,
77
+ validated_inputs: dict[str, Any],
78
+ context: NodeExecutionContext,
79
+ ) -> DeleteOutput:
80
+ credential_data = await context.resolve_credential(
81
+ credential_id=validated_inputs["credential_id"],
82
+ credential_type=self.get_credential_type(),
83
+ )
84
+ context.register_secret(credential_data["password"])
85
+
86
+ query, params = _build_delete_query(validated_inputs)
87
+ timeout = validated_inputs.get("timeout", 30)
88
+
89
+ rows, row_count = await execute_query(credential_data, query, params, timeout=timeout)
90
+ return DeleteOutput(
91
+ rows=serialize_rows(rows),
92
+ affected_count=row_count,
93
+ success=True,
94
+ raw_sql=query,
95
+ )
96
+
97
+
98
+ def _build_delete_query(inputs: dict[str, Any]) -> tuple[str, list[Any]]:
99
+ """Build a parameterized DELETE query from structured inputs."""
100
+ table = qualify_table(inputs["table"], inputs.get("schema"))
101
+
102
+ # WHERE clause (required)
103
+ where_sql, params, _ = build_where_clause(inputs["where"])
104
+
105
+ query = f"DELETE FROM {table} {where_sql}"
106
+
107
+ # RETURNING
108
+ returning = inputs.get("returning") or []
109
+ if returning:
110
+ ret_str = ", ".join(sanitize_identifier(c) for c in returning)
111
+ query += f" RETURNING {ret_str}"
112
+
113
+ return query, params
@@ -0,0 +1,146 @@
1
+ """Structured INSERT node for PostgreSQL."""
2
+
3
+ from typing import Any
4
+
5
+ from flowire_sdk import BaseNode, BaseNodeOutput, InputField, NodeExecutionContext, NodeMetadata
6
+ from flowire_sdk.node_base import FieldOption
7
+ from pydantic import BaseModel, Field
8
+
9
+ from fw_nodes_postgres.credentials import PostgresCredentialSchema
10
+ from fw_nodes_postgres.utils.db import (
11
+ execute_query,
12
+ pg_field_options,
13
+ qualify_table,
14
+ sanitize_identifier,
15
+ serialize_rows,
16
+ )
17
+
18
+
19
+ class InsertInput(BaseModel):
20
+ credential_id: str = InputField(..., description="PostgreSQL credential")
21
+ schema: str | None = InputField(
22
+ default=None,
23
+ description="PostgreSQL schema (defaults to search_path, usually 'public')",
24
+ dynamic_options=["credential_id"],
25
+ )
26
+ table: str = InputField(
27
+ ...,
28
+ description="Table to insert into",
29
+ dynamic_options=["credential_id", "schema"],
30
+ )
31
+ data: dict[str, Any] | list[dict[str, Any]] = InputField(
32
+ ...,
33
+ description="Row data as key-value pairs, or a list of rows for bulk insert",
34
+ )
35
+ returning: list[str] = InputField(
36
+ default_factory=list,
37
+ description="Columns to return from inserted rows (e.g., ['id', 'created_at'])",
38
+ )
39
+ timeout: int = InputField(default=30, ge=1, le=300, description="Query timeout in seconds")
40
+
41
+
42
+ class InsertOutput(BaseNodeOutput):
43
+ rows: list[dict[str, Any]] = Field(..., description="Returned rows (if RETURNING was specified)")
44
+ affected_count: int = Field(..., description="Number of rows inserted")
45
+ success: bool = Field(..., description="Whether the insert executed successfully")
46
+ raw_sql: str = Field(..., description="The generated SQL query (with $N placeholders)")
47
+
48
+
49
+ class InsertNode(BaseNode):
50
+ """Insert rows into a PostgreSQL table."""
51
+
52
+ input_schema = InsertInput
53
+ output_schema = InsertOutput
54
+ credential_schema = PostgresCredentialSchema
55
+
56
+ metadata = NodeMetadata(
57
+ name="Postgres Insert",
58
+ description="Insert rows into a PostgreSQL table",
59
+ category="database",
60
+ icon="database",
61
+ color="#336791",
62
+ )
63
+
64
+ async def get_field_options(
65
+ self,
66
+ field_name: str,
67
+ credential_data: dict[str, Any] | None = None,
68
+ field_values: dict[str, Any] | None = None,
69
+ ) -> list[FieldOption]:
70
+ return await pg_field_options(field_name, credential_data, field_values)
71
+
72
+ async def execute_logic(
73
+ self,
74
+ validated_inputs: dict[str, Any],
75
+ context: NodeExecutionContext,
76
+ ) -> InsertOutput:
77
+ credential_data = await context.resolve_credential(
78
+ credential_id=validated_inputs["credential_id"],
79
+ credential_type=self.get_credential_type(),
80
+ )
81
+ context.register_secret(credential_data["password"])
82
+
83
+ query, params = _build_insert_query(validated_inputs)
84
+ timeout = validated_inputs.get("timeout", 30)
85
+
86
+ rows, row_count = await execute_query(credential_data, query, params, timeout=timeout)
87
+ return InsertOutput(
88
+ rows=serialize_rows(rows),
89
+ affected_count=row_count,
90
+ success=True,
91
+ raw_sql=query,
92
+ )
93
+
94
+
95
+ def _build_insert_query(inputs: dict[str, Any]) -> tuple[str, list[Any]]:
96
+ """Build a parameterized INSERT query from structured inputs."""
97
+ table = qualify_table(inputs["table"], inputs.get("schema"))
98
+ data = inputs["data"]
99
+
100
+ # Normalize to list of rows
101
+ rows_data = data if isinstance(data, list) else [data]
102
+ if not rows_data:
103
+ raise ValueError("No data provided for insert")
104
+
105
+ # Use column order from the first row
106
+ columns = list(rows_data[0].keys())
107
+ if not columns:
108
+ raise ValueError("No columns provided for insert")
109
+
110
+ # Validate all rows have the same columns
111
+ expected = set(columns)
112
+ for i, row in enumerate(rows_data[1:], start=2):
113
+ actual = set(row.keys())
114
+ if actual != expected:
115
+ missing = expected - actual
116
+ extra = actual - expected
117
+ parts = []
118
+ if missing:
119
+ parts.append(f"missing {missing}")
120
+ if extra:
121
+ parts.append(f"unexpected {extra}")
122
+ raise ValueError(f"Row {i} has inconsistent columns: {', '.join(parts)}")
123
+
124
+ col_str = ", ".join(sanitize_identifier(c) for c in columns)
125
+
126
+ params: list[Any] = []
127
+ value_groups: list[str] = []
128
+ param_idx = 1
129
+
130
+ for row in rows_data:
131
+ placeholders = []
132
+ for col in columns:
133
+ placeholders.append(f"${param_idx}")
134
+ params.append(row.get(col))
135
+ param_idx += 1
136
+ value_groups.append(f"({', '.join(placeholders)})")
137
+
138
+ query = f"INSERT INTO {table} ({col_str}) VALUES {', '.join(value_groups)}"
139
+
140
+ # RETURNING
141
+ returning = inputs.get("returning") or []
142
+ if returning:
143
+ ret_str = ", ".join(sanitize_identifier(c) for c in returning)
144
+ query += f" RETURNING {ret_str}"
145
+
146
+ return query, params
@@ -0,0 +1,79 @@
1
+ """Raw SQL query node for executing arbitrary SQL against PostgreSQL."""
2
+
3
+ from typing import Any
4
+
5
+ from flowire_sdk import BaseNode, BaseNodeOutput, CodeEditorContract, InputField, NodeExecutionContext, NodeMetadata
6
+ from pydantic import BaseModel, Field
7
+
8
+ from fw_nodes_postgres.credentials import PostgresCredentialSchema
9
+ from fw_nodes_postgres.utils.db import execute_query, serialize_rows
10
+
11
+ SQL_DEFAULT = "SELECT * FROM my_table\nWHERE id = $1\nLIMIT 100;"
12
+
13
+
14
+ class RawQueryInput(BaseModel):
15
+ credential_id: str = InputField(..., description="PostgreSQL credential")
16
+ query: str = InputField(
17
+ default=SQL_DEFAULT,
18
+ description="SQL query to execute. Use $1, $2, etc. for parameterized values.",
19
+ code=True,
20
+ )
21
+ params: list[Any] = InputField(
22
+ default_factory=list,
23
+ description="Query parameters (matched to $1, $2, etc. in the query)",
24
+ )
25
+ timeout: int = InputField(default=30, ge=1, le=300, description="Query timeout in seconds")
26
+
27
+
28
+ class RawQueryOutput(BaseNodeOutput):
29
+ rows: list[dict[str, Any]] = Field(..., description="Query result rows (empty for non-SELECT queries)")
30
+ row_count: int = Field(..., description="Number of rows returned or affected")
31
+ success: bool = Field(..., description="Whether the query executed successfully")
32
+ raw_sql: str = Field(..., description="The SQL query that was executed")
33
+
34
+
35
+ class RawQueryNode(BaseNode):
36
+ """Execute raw SQL queries against a PostgreSQL database."""
37
+
38
+ input_schema = RawQueryInput
39
+ output_schema = RawQueryOutput
40
+ credential_schema = PostgresCredentialSchema
41
+
42
+ metadata = NodeMetadata(
43
+ name="Postgres Raw Query",
44
+ description="Execute raw SQL queries against PostgreSQL",
45
+ category="database",
46
+ icon="database",
47
+ color="#336791",
48
+ display_component="code",
49
+ code_editor=CodeEditorContract(
50
+ code_field="query",
51
+ language_field=None,
52
+ code_format="code",
53
+ language_defaults={"sql": SQL_DEFAULT},
54
+ language_options=["sql"],
55
+ ),
56
+ )
57
+
58
+ async def execute_logic(
59
+ self,
60
+ validated_inputs: dict[str, Any],
61
+ context: NodeExecutionContext,
62
+ ) -> RawQueryOutput:
63
+ credential_data = await context.resolve_credential(
64
+ credential_id=validated_inputs["credential_id"],
65
+ credential_type=self.get_credential_type(),
66
+ )
67
+ context.register_secret(credential_data["password"])
68
+
69
+ query = validated_inputs["query"]
70
+ params = validated_inputs.get("params") or []
71
+ timeout = validated_inputs.get("timeout", 30)
72
+
73
+ rows, row_count = await execute_query(credential_data, query, params, timeout=timeout)
74
+ return RawQueryOutput(
75
+ rows=serialize_rows(rows),
76
+ row_count=row_count,
77
+ success=True,
78
+ raw_sql=query,
79
+ )
@@ -0,0 +1,151 @@
1
+ """Structured SELECT node for PostgreSQL."""
2
+
3
+ from enum import StrEnum
4
+ from typing import Any
5
+
6
+ from flowire_sdk import BaseNode, BaseNodeOutput, InputField, NodeExecutionContext, NodeMetadata
7
+ from flowire_sdk.node_base import FieldOption
8
+ from pydantic import BaseModel, Field
9
+
10
+ from fw_nodes_postgres.credentials import PostgresCredentialSchema
11
+ from fw_nodes_postgres.utils.db import (
12
+ WhereCondition,
13
+ build_where_clause,
14
+ execute_query,
15
+ pg_field_options,
16
+ qualify_table,
17
+ sanitize_identifier,
18
+ serialize_rows,
19
+ )
20
+
21
+
22
+ class SortDirection(StrEnum):
23
+ ASC = "ASC"
24
+ DESC = "DESC"
25
+
26
+
27
+ class SortClause(BaseModel):
28
+ column: str = InputField(..., description="Column to sort by")
29
+ direction: SortDirection = InputField(default=SortDirection.ASC, description="Sort direction")
30
+
31
+
32
+ class SelectInput(BaseModel):
33
+ credential_id: str = InputField(..., description="PostgreSQL credential")
34
+ schema: str | None = InputField(
35
+ default=None,
36
+ description="PostgreSQL schema (defaults to search_path, usually 'public')",
37
+ dynamic_options=["credential_id"],
38
+ )
39
+ table: str = InputField(
40
+ ...,
41
+ description="Table name to query",
42
+ dynamic_options=["credential_id", "schema"],
43
+ )
44
+ columns: list[str] = InputField(
45
+ default_factory=lambda: ["*"],
46
+ description="Columns to select (defaults to all)",
47
+ )
48
+ where: list[WhereCondition] = InputField(
49
+ default_factory=list,
50
+ description="WHERE conditions (combined with AND)",
51
+ )
52
+ order_by: list[SortClause] = InputField(default_factory=list, description="ORDER BY clauses")
53
+ limit: int | None = InputField(default=None, ge=1, description="Maximum rows to return")
54
+ offset: int | None = InputField(default=None, ge=0, description="Number of rows to skip")
55
+ timeout: int = InputField(default=30, ge=1, le=300, description="Query timeout in seconds")
56
+
57
+
58
+ class SelectOutput(BaseNodeOutput):
59
+ rows: list[dict[str, Any]] = Field(..., description="Query result rows")
60
+ row_count: int = Field(..., description="Number of rows returned")
61
+ success: bool = Field(..., description="Whether the query executed successfully")
62
+ raw_sql: str = Field(..., description="The generated SQL query (with $N placeholders)")
63
+
64
+
65
+ class SelectNode(BaseNode):
66
+ """Query rows from a PostgreSQL table with a structured interface."""
67
+
68
+ input_schema = SelectInput
69
+ output_schema = SelectOutput
70
+ credential_schema = PostgresCredentialSchema
71
+
72
+ metadata = NodeMetadata(
73
+ name="Postgres Select",
74
+ description="Query rows from a PostgreSQL table",
75
+ category="database",
76
+ icon="database",
77
+ color="#336791",
78
+ )
79
+
80
+ async def get_field_options(
81
+ self,
82
+ field_name: str,
83
+ credential_data: dict[str, Any] | None = None,
84
+ field_values: dict[str, Any] | None = None,
85
+ ) -> list[FieldOption]:
86
+ return await pg_field_options(field_name, credential_data, field_values)
87
+
88
+ async def execute_logic(
89
+ self,
90
+ validated_inputs: dict[str, Any],
91
+ context: NodeExecutionContext,
92
+ ) -> SelectOutput:
93
+ credential_data = await context.resolve_credential(
94
+ credential_id=validated_inputs["credential_id"],
95
+ credential_type=self.get_credential_type(),
96
+ )
97
+ context.register_secret(credential_data["password"])
98
+
99
+ query, params = _build_select_query(validated_inputs)
100
+ timeout = validated_inputs.get("timeout", 30)
101
+
102
+ rows, row_count = await execute_query(credential_data, query, params, timeout=timeout)
103
+ return SelectOutput(
104
+ rows=serialize_rows(rows),
105
+ row_count=row_count,
106
+ success=True,
107
+ raw_sql=query,
108
+ )
109
+
110
+
111
+ def _build_select_query(inputs: dict[str, Any]) -> tuple[str, list[Any]]:
112
+ """Build a parameterized SELECT query from structured inputs."""
113
+ table = qualify_table(inputs["table"], inputs.get("schema"))
114
+ columns = inputs.get("columns") or ["*"]
115
+ col_str = ", ".join(sanitize_identifier(c) if c != "*" else "*" for c in columns)
116
+
117
+ parts = [f"SELECT {col_str} FROM {table}"]
118
+ params: list[Any] = []
119
+ param_idx = 1
120
+
121
+ # WHERE
122
+ where_conditions = inputs.get("where") or []
123
+ if where_conditions:
124
+ where_sql, where_params, param_idx = build_where_clause(where_conditions, param_idx)
125
+ parts.append(where_sql)
126
+ params.extend(where_params)
127
+
128
+ # ORDER BY
129
+ order_by = inputs.get("order_by") or []
130
+ if order_by:
131
+ order_parts = []
132
+ for clause in order_by:
133
+ col = sanitize_identifier(clause["column"])
134
+ direction = clause.get("direction", "ASC").upper()
135
+ if direction not in ("ASC", "DESC"):
136
+ direction = "ASC"
137
+ order_parts.append(f"{col} {direction}")
138
+ parts.append("ORDER BY " + ", ".join(order_parts))
139
+
140
+ # LIMIT / OFFSET
141
+ if inputs.get("limit") is not None:
142
+ parts.append(f"LIMIT ${param_idx}")
143
+ params.append(inputs["limit"])
144
+ param_idx += 1
145
+
146
+ if inputs.get("offset") is not None:
147
+ parts.append(f"OFFSET ${param_idx}")
148
+ params.append(inputs["offset"])
149
+ param_idx += 1
150
+
151
+ return " ".join(parts), params
@@ -0,0 +1,129 @@
1
+ """Structured UPDATE node for PostgreSQL."""
2
+
3
+ from typing import Any
4
+
5
+ from flowire_sdk import BaseNode, BaseNodeOutput, InputField, NodeExecutionContext, NodeMetadata
6
+ from flowire_sdk.node_base import FieldOption
7
+ from pydantic import BaseModel, Field
8
+
9
+ from fw_nodes_postgres.credentials import PostgresCredentialSchema
10
+ from fw_nodes_postgres.utils.db import (
11
+ WhereCondition,
12
+ build_where_clause,
13
+ execute_query,
14
+ pg_field_options,
15
+ qualify_table,
16
+ sanitize_identifier,
17
+ serialize_rows,
18
+ )
19
+
20
+
21
+ class UpdateInput(BaseModel):
22
+ credential_id: str = InputField(..., description="PostgreSQL credential")
23
+ schema: str | None = InputField(
24
+ default=None,
25
+ description="PostgreSQL schema (defaults to search_path, usually 'public')",
26
+ dynamic_options=["credential_id"],
27
+ )
28
+ table: str = InputField(
29
+ ...,
30
+ description="Table to update",
31
+ dynamic_options=["credential_id", "schema"],
32
+ )
33
+ data: dict[str, Any] = InputField(..., description="Column values to set (key-value pairs)")
34
+ where: list[WhereCondition] = InputField(
35
+ ...,
36
+ min_length=1,
37
+ description="WHERE conditions (at least one required to prevent accidental full-table updates)",
38
+ )
39
+ returning: list[str] = InputField(
40
+ default_factory=list,
41
+ description="Columns to return from updated rows",
42
+ )
43
+ timeout: int = InputField(default=30, ge=1, le=300, description="Query timeout in seconds")
44
+
45
+
46
+ class UpdateOutput(BaseNodeOutput):
47
+ rows: list[dict[str, Any]] = Field(..., description="Returned rows (if RETURNING was specified)")
48
+ affected_count: int = Field(..., description="Number of rows updated")
49
+ success: bool = Field(..., description="Whether the update executed successfully")
50
+ raw_sql: str = Field(..., description="The generated SQL query (with $N placeholders)")
51
+
52
+
53
+ class UpdateNode(BaseNode):
54
+ """Update rows in a PostgreSQL table."""
55
+
56
+ input_schema = UpdateInput
57
+ output_schema = UpdateOutput
58
+ credential_schema = PostgresCredentialSchema
59
+
60
+ metadata = NodeMetadata(
61
+ name="Postgres Update",
62
+ description="Update rows in a PostgreSQL table",
63
+ category="database",
64
+ icon="database",
65
+ color="#336791",
66
+ )
67
+
68
+ async def get_field_options(
69
+ self,
70
+ field_name: str,
71
+ credential_data: dict[str, Any] | None = None,
72
+ field_values: dict[str, Any] | None = None,
73
+ ) -> list[FieldOption]:
74
+ return await pg_field_options(field_name, credential_data, field_values)
75
+
76
+ async def execute_logic(
77
+ self,
78
+ validated_inputs: dict[str, Any],
79
+ context: NodeExecutionContext,
80
+ ) -> UpdateOutput:
81
+ credential_data = await context.resolve_credential(
82
+ credential_id=validated_inputs["credential_id"],
83
+ credential_type=self.get_credential_type(),
84
+ )
85
+ context.register_secret(credential_data["password"])
86
+
87
+ query, params = _build_update_query(validated_inputs)
88
+ timeout = validated_inputs.get("timeout", 30)
89
+
90
+ rows, row_count = await execute_query(credential_data, query, params, timeout=timeout)
91
+ return UpdateOutput(
92
+ rows=serialize_rows(rows),
93
+ affected_count=row_count,
94
+ success=True,
95
+ raw_sql=query,
96
+ )
97
+
98
+
99
+ def _build_update_query(inputs: dict[str, Any]) -> tuple[str, list[Any]]:
100
+ """Build a parameterized UPDATE query from structured inputs."""
101
+ table = qualify_table(inputs["table"], inputs.get("schema"))
102
+ data = inputs["data"]
103
+
104
+ if not data:
105
+ raise ValueError("No data provided for update")
106
+
107
+ params: list[Any] = []
108
+ param_idx = 1
109
+
110
+ # SET clause
111
+ set_parts = []
112
+ for col, val in data.items():
113
+ set_parts.append(f"{sanitize_identifier(col)} = ${param_idx}")
114
+ params.append(val)
115
+ param_idx += 1
116
+
117
+ # WHERE clause (required)
118
+ where_sql, where_params, param_idx = build_where_clause(inputs["where"], param_idx)
119
+ params.extend(where_params)
120
+
121
+ query = f"UPDATE {table} SET {', '.join(set_parts)} {where_sql}"
122
+
123
+ # RETURNING
124
+ returning = inputs.get("returning") or []
125
+ if returning:
126
+ ret_str = ", ".join(sanitize_identifier(c) for c in returning)
127
+ query += f" RETURNING {ret_str}"
128
+
129
+ return query, params
File without changes
@@ -0,0 +1,255 @@
1
+ """Shared database utilities for PostgreSQL nodes."""
2
+
3
+ from decimal import Decimal
4
+ from typing import Any
5
+
6
+ import asyncpg
7
+ from flowire_sdk import InputField
8
+ from flowire_sdk.node_base import FieldOption
9
+ from pydantic import BaseModel
10
+
11
+
12
+ class WhereCondition(BaseModel):
13
+ column: str = InputField(..., description="Column name")
14
+ operator: str = InputField(
15
+ default="=",
16
+ description="Comparison operator (=, !=, <, >, <=, >=, LIKE, ILIKE, IN, IS NULL, IS NOT NULL)",
17
+ )
18
+ value: Any = InputField(default=None, description="Comparison value (ignored for IS NULL / IS NOT NULL)")
19
+
20
+
21
+ _ALLOWED_OPERATORS = frozenset({"=", "!=", "<>", "<", ">", "<=", ">=", "LIKE", "ILIKE"})
22
+
23
+
24
+ def sanitize_identifier(name: str) -> str:
25
+ """Quote a SQL identifier to prevent injection."""
26
+ cleaned = name.replace('"', '""')
27
+ return f'"{cleaned}"'
28
+
29
+
30
+ def qualify_table(table: str, schema: str | None = None) -> str:
31
+ """Return a fully-qualified, quoted table reference (e.g. "myschema"."mytable")."""
32
+ qualified = sanitize_identifier(table)
33
+ if schema:
34
+ qualified = f"{sanitize_identifier(schema)}.{qualified}"
35
+ return qualified
36
+
37
+
38
+ def build_where_clause(
39
+ conditions: list[dict[str, Any]],
40
+ param_idx: int = 1,
41
+ ) -> tuple[str, list[Any], int]:
42
+ """Build a parameterized WHERE clause from a list of conditions.
43
+
44
+ Returns (clause_sql, params, next_param_idx).
45
+ clause_sql includes the 'WHERE' keyword.
46
+ """
47
+ clauses: list[str] = []
48
+ params: list[Any] = []
49
+
50
+ for cond in conditions:
51
+ col = sanitize_identifier(cond["column"])
52
+ op = cond.get("operator", "=").upper().strip()
53
+
54
+ if op in ("IS NULL", "IS NOT NULL"):
55
+ clauses.append(f"{col} {op}")
56
+ elif op == "IN":
57
+ values = cond["value"] if isinstance(cond["value"], list) else [cond["value"]]
58
+ placeholders = ", ".join(f"${param_idx + i}" for i in range(len(values)))
59
+ clauses.append(f"{col} IN ({placeholders})")
60
+ params.extend(values)
61
+ param_idx += len(values)
62
+ else:
63
+ if op not in _ALLOWED_OPERATORS:
64
+ raise ValueError(f"Unsupported operator: {op}")
65
+ clauses.append(f"{col} {op} ${param_idx}")
66
+ params.append(cond["value"])
67
+ param_idx += 1
68
+
69
+ return "WHERE " + " AND ".join(clauses), params, param_idx
70
+
71
+
72
+ async def execute_query(
73
+ credential_data: dict[str, Any],
74
+ query: str,
75
+ params: list[Any] | None = None,
76
+ *,
77
+ timeout: float = 30.0,
78
+ ) -> tuple[list[dict[str, Any]], int]:
79
+ """Execute a query and return (rows, row_count).
80
+
81
+ For SELECT queries, rows contains the result set and row_count is len(rows).
82
+ For INSERT/UPDATE/DELETE, rows is empty and row_count is the affected row count.
83
+ Returns with RETURNING clauses return the rows and len(rows) as row_count.
84
+ """
85
+ ssl_config: str | bool = "require" if credential_data.get("ssl") else False
86
+
87
+ try:
88
+ conn: asyncpg.Connection = await asyncpg.connect(
89
+ host=credential_data["host"],
90
+ port=credential_data.get("port", 5432),
91
+ database=credential_data["database"],
92
+ user=credential_data["user"],
93
+ password=credential_data["password"],
94
+ ssl=ssl_config,
95
+ timeout=timeout,
96
+ )
97
+ except OSError as e:
98
+ raise ConnectionError(
99
+ f"Cannot connect to PostgreSQL at {credential_data['host']}:{credential_data.get('port', 5432)}: {e}"
100
+ ) from e
101
+ except asyncpg.InvalidPasswordError as e:
102
+ raise ConnectionError("Authentication failed: invalid username or password") from e
103
+ except asyncpg.InvalidCatalogNameError as e:
104
+ raise ConnectionError(f"Database '{credential_data['database']}' does not exist") from e
105
+ except TimeoutError as e:
106
+ raise TimeoutError(
107
+ f"Connection timed out after {timeout}s to {credential_data['host']}:{credential_data.get('port', 5432)}"
108
+ ) from e
109
+ except asyncpg.PostgresError as e:
110
+ raise ConnectionError(f"PostgreSQL connection error: {e}") from e
111
+
112
+ try:
113
+ async with conn.transaction():
114
+ stmt = await conn.prepare(query, timeout=timeout)
115
+ if stmt.get_attributes():
116
+ records = await stmt.fetch(*(params or []), timeout=timeout)
117
+ rows = [dict(r) for r in records]
118
+ return rows, len(rows)
119
+ else:
120
+ result = await conn.execute(query, *(params or []), timeout=timeout)
121
+ row_count = _parse_command_tag(result)
122
+ return [], row_count
123
+ except asyncpg.PostgresSyntaxError as e:
124
+ raise ValueError(f"SQL syntax error: {e}") from e
125
+ except asyncpg.UndefinedTableError as e:
126
+ raise ValueError(f"Table not found: {e}") from e
127
+ except asyncpg.UndefinedColumnError as e:
128
+ raise ValueError(f"Column not found: {e}") from e
129
+ except asyncpg.DataError as e:
130
+ raise ValueError(f"Data error: {e}") from e
131
+ except TimeoutError as e:
132
+ raise TimeoutError(f"Query timed out after {timeout}s") from e
133
+ except asyncpg.PostgresError as e:
134
+ raise RuntimeError(f"PostgreSQL error: {e}") from e
135
+ finally:
136
+ await conn.close()
137
+
138
+
139
+ def _parse_command_tag(tag: str) -> int:
140
+ """Parse asyncpg command tag like 'DELETE 3' into affected row count."""
141
+ parts = tag.split()
142
+ if len(parts) >= 2 and parts[-1].isdigit():
143
+ return int(parts[-1])
144
+ return 0
145
+
146
+
147
+ def _serialize_value(value: Any) -> Any:
148
+ """Convert asyncpg types to JSON-serializable Python types.
149
+
150
+ Preserves native JSON types (int, float, bool) and converts
151
+ Decimal to int/float so downstream nodes get usable numbers.
152
+ """
153
+ if value is None:
154
+ return None
155
+ if isinstance(value, bool):
156
+ # Check bool before int since bool is a subclass of int
157
+ return value
158
+ if isinstance(value, (int, float)):
159
+ return value
160
+ if isinstance(value, str):
161
+ return value
162
+ if isinstance(value, Decimal):
163
+ # Preserve as int when there's no fractional part
164
+ if value == value.to_integral_value():
165
+ return int(value)
166
+ return float(value)
167
+ if isinstance(value, (list, tuple)):
168
+ return [_serialize_value(v) for v in value]
169
+ if isinstance(value, dict):
170
+ return {k: _serialize_value(v) for k, v in value.items()}
171
+ # datetime, date, time, UUID, etc.
172
+ return str(value)
173
+
174
+
175
+ def serialize_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
176
+ """Serialize all values in rows to JSON-safe types."""
177
+ return [{k: _serialize_value(v) for k, v in row.items()} for row in rows]
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Dynamic field options helpers
182
+ # ---------------------------------------------------------------------------
183
+
184
+ # Schemas to hide from the dropdown (PostgreSQL internals)
185
+ _HIDDEN_SCHEMAS = frozenset({"pg_catalog", "pg_toast", "information_schema"})
186
+
187
+
188
+ async def _connect(credential_data: dict[str, Any]) -> asyncpg.Connection:
189
+ """Open a short-lived connection for metadata queries."""
190
+ ssl_config: str | bool = "require" if credential_data.get("ssl") else False
191
+ return await asyncpg.connect(
192
+ host=credential_data["host"],
193
+ port=credential_data.get("port", 5432),
194
+ database=credential_data["database"],
195
+ user=credential_data["user"],
196
+ password=credential_data["password"],
197
+ ssl=ssl_config,
198
+ timeout=10,
199
+ )
200
+
201
+
202
+ async def fetch_schemas(credential_data: dict[str, Any]) -> list[FieldOption]:
203
+ """List user-visible schemas from the connected database."""
204
+ conn = await _connect(credential_data)
205
+ try:
206
+ rows = await conn.fetch("SELECT schema_name FROM information_schema.schemata ORDER BY schema_name")
207
+ return [
208
+ FieldOption(id=r["schema_name"], name=r["schema_name"])
209
+ for r in rows
210
+ if r["schema_name"] not in _HIDDEN_SCHEMAS
211
+ ]
212
+ finally:
213
+ await conn.close()
214
+
215
+
216
+ async def fetch_tables(
217
+ credential_data: dict[str, Any],
218
+ schema: str | None = None,
219
+ ) -> list[FieldOption]:
220
+ """List tables (and views) in a schema. Defaults to 'public'."""
221
+ schema = schema or "public"
222
+ conn = await _connect(credential_data)
223
+ try:
224
+ rows = await conn.fetch(
225
+ "SELECT table_name, table_type FROM information_schema.tables WHERE table_schema = $1 ORDER BY table_name",
226
+ schema,
227
+ )
228
+ return [
229
+ FieldOption(
230
+ id=r["table_name"],
231
+ name=r["table_name"],
232
+ description="view" if r["table_type"] == "VIEW" else None,
233
+ )
234
+ for r in rows
235
+ ]
236
+ finally:
237
+ await conn.close()
238
+
239
+
240
+ async def pg_field_options(
241
+ field_name: str,
242
+ credential_data: dict[str, Any] | None,
243
+ field_values: dict[str, Any] | None = None,
244
+ ) -> list[FieldOption]:
245
+ """Shared get_field_options implementation for all PostgreSQL CRUD nodes."""
246
+ if credential_data is None:
247
+ return []
248
+
249
+ if field_name == "schema":
250
+ return await fetch_schemas(credential_data)
251
+ elif field_name == "table":
252
+ schema = (field_values or {}).get("schema")
253
+ return await fetch_tables(credential_data, schema)
254
+
255
+ return []
@@ -0,0 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: fw-nodes-postgres
3
+ Version: 0.0.1a1
4
+ Summary: PostgreSQL database nodes for Flowire workflow automation
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: asyncpg>=0.30.0
8
+ Requires-Dist: flowire-sdk>=0.0.1a1
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
11
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
12
+ Requires-Dist: ruff>=0.4.0; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # fw-nodes-postgres
16
+
17
+ PostgreSQL database nodes for Flowire workflow automation.
18
+
19
+ ## Nodes
20
+
21
+ - **Postgres Raw Query** — Execute raw SQL with a code editor and parameterized values
22
+ - **Postgres Select** — Structured query builder for SELECT operations
23
+ - **Postgres Insert** — Insert single or bulk rows
24
+ - **Postgres Update** — Update rows with required WHERE conditions
25
+ - **Postgres Delete** — Delete rows with required WHERE conditions
@@ -0,0 +1,14 @@
1
+ fw_nodes_postgres/__init__.py,sha256=CxCjSWye6NI41h2VKl8C3v7YEnVIPmIMDwgDQ16FHbg,56
2
+ fw_nodes_postgres/credentials.py,sha256=nXo-NgL_fxBeCmXoz5I5KiFftS5o07LUs2sdYNOnQ0E,897
3
+ fw_nodes_postgres/nodes/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ fw_nodes_postgres/nodes/delete.py,sha256=5hOhnomDhb_ORz0OaKxV_OQjglBc_qjbK1QjbPNIilU,3810
5
+ fw_nodes_postgres/nodes/insert.py,sha256=BLk6_po7uKyTc86QCWFsfbMSYwREQw5Fd6NfuA7pf24,4999
6
+ fw_nodes_postgres/nodes/raw_query.py,sha256=8sgjqxzgRVaostkqAqnDXK5yZiim1rmrG3Ocq_SStHo,2895
7
+ fw_nodes_postgres/nodes/select.py,sha256=wz96sjtibOwLV13lWQveLcojTQIxCLHWH06UXE5nVm4,5243
8
+ fw_nodes_postgres/nodes/update.py,sha256=HJ-TqQxmFpFYMb0rnyMB7j83GPfDzKCqPhg69VI225U,4315
9
+ fw_nodes_postgres/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ fw_nodes_postgres/utils/db.py,sha256=xyEJnsnBxdOR6--CZwKQ1BzvtqkcubG5xembAWJXZ44,9296
11
+ fw_nodes_postgres-0.0.1a1.dist-info/METADATA,sha256=W2ec0PPy86ByimJm608UxK2F7oweiQVzkOYu_bouOTw,892
12
+ fw_nodes_postgres-0.0.1a1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
+ fw_nodes_postgres-0.0.1a1.dist-info/entry_points.txt,sha256=USDmzbO4r6dVAurrfEXPJUXK_0kcEhcLV-K8R6dBPNY,294
14
+ fw_nodes_postgres-0.0.1a1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,6 @@
1
+ [flowire.nodes]
2
+ pg_delete = fw_nodes_postgres.nodes.delete:DeleteNode
3
+ pg_insert = fw_nodes_postgres.nodes.insert:InsertNode
4
+ pg_raw_query = fw_nodes_postgres.nodes.raw_query:RawQueryNode
5
+ pg_select = fw_nodes_postgres.nodes.select:SelectNode
6
+ pg_update = fw_nodes_postgres.nodes.update:UpdateNode