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.
- fw_nodes_postgres/__init__.py +1 -0
- fw_nodes_postgres/credentials.py +23 -0
- fw_nodes_postgres/nodes/__init__.py +0 -0
- fw_nodes_postgres/nodes/delete.py +113 -0
- fw_nodes_postgres/nodes/insert.py +146 -0
- fw_nodes_postgres/nodes/raw_query.py +79 -0
- fw_nodes_postgres/nodes/select.py +151 -0
- fw_nodes_postgres/nodes/update.py +129 -0
- fw_nodes_postgres/utils/__init__.py +0 -0
- fw_nodes_postgres/utils/db.py +255 -0
- fw_nodes_postgres-0.0.1a1.dist-info/METADATA +25 -0
- fw_nodes_postgres-0.0.1a1.dist-info/RECORD +14 -0
- fw_nodes_postgres-0.0.1a1.dist-info/WHEEL +4 -0
- fw_nodes_postgres-0.0.1a1.dist-info/entry_points.txt +6 -0
|
@@ -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,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
|