AutomationFlowsData & Sheets › Query Postgresql From Slack Using Google Sheets Metadata and Gemini

Query Postgresql From Slack Using Google Sheets Metadata and Gemini

ByRishabh Patil @rishabh-patil on n8n.io

This workflow lets Slack users ask natural-language questions about a PostgreSQL database, uses Google Sheets metadata plus live schema data to generate and validate a safe read-only SQL query with Google Gemini, executes it, and replies in Slack with a formatted answer.…

Event trigger★★★★★ complexityAI-powered30 nodesSlack TriggerGoogle GeminiGoogle SheetsPostgresSlack
Data & Sheets Trigger: Event Nodes: 30 Complexity: ★★★★★ AI nodes: yes Added:
Query Postgresql From Slack Using Google Sheets Metadata and Gemini — n8n workflow card showing Slack Trigger, Google Gemini, Google Sheets integration

This workflow corresponds to n8n.io template #17442 — we link there as the canonical source.

This workflow follows the Googlegemini → Google Sheets recipe pattern — see all workflows that pair these two integrations.

The workflow JSON

Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →

Download .json
{
  "id": "i7wvNcab1AglVYxh",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "AI Database Assistant for Slack",
  "tags": [],
  "nodes": [
    {
      "id": "52a36e7d-d0fc-4536-b3d4-1a49a908d205",
      "name": "Slack Trigger",
      "type": "n8n-nodes-base.slackTrigger",
      "position": [
        -128,
        32
      ],
      "parameters": {
        "options": {},
        "trigger": [
          "app_mention"
        ],
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": "",
          "cachedResultUrl": "",
          "cachedResultName": ""
        }
      },
      "typeVersion": 1
    },
    {
      "id": "1c8a03cc-ca9c-4723-b1cd-73f8c4801d83",
      "name": "Validate LLM Output",
      "type": "n8n-nodes-base.code",
      "position": [
        1888,
        -144
      ],
      "parameters": {
        "jsCode": "let sql = $json.sql?.trim() || \"\";\nlet explanation = $json.explanation || \"\";\n\n// Case 1: LLM refused / did not generate SQL\nif (!sql) {\n  return [{\n    json: {\n      valid: false,\n      type: \"no_sql\",\n      reason: explanation || \"No SQL query generated.\",\n      sql\n    }\n  }];\n}\n\n// Continue validation only when SQL exists\nconst upperSql = sql.toUpperCase();\n\nconst forbidden = [\n  \"INSERT\",\n  \"UPDATE\",\n  \"DELETE\",\n  \"DROP\",\n  \"ALTER\",\n  \"CREATE\",\n  \"TRUNCATE\",\n  \"GRANT\",\n  \"REVOKE\",\n  \"COPY\",\n  \"EXECUTE\",\n  \"DO\"\n];\n\n// Must start with SELECT\nif (!upperSql.startsWith(\"SELECT\")) {\n  return [{\n    json: {\n      valid: false,\n      type: \"invalid_sql\",\n      reason: \"Only SELECT queries are allowed.\",\n      sql\n    }\n  }];\n}\n\n// Reject multiple statements\nconst semicolons = (sql.match(/;/g) || []).length;\n\nif (semicolons > 1) {\n  return [{\n    json: {\n      valid: false,\n      type: \"invalid_sql\",\n      reason: \"Multiple SQL statements are not allowed.\",\n      sql\n    }\n  }];\n}\n\n// Forbidden keywords\nfor (const keyword of forbidden) {\n  const regex = new RegExp(`\\\\b${keyword}\\\\b`, \"i\");\n\n  if (regex.test(sql)) {\n    return [{\n      json: {\n        valid: false,\n        type: \"invalid_sql\",\n        reason: `Forbidden keyword detected: ${keyword}`,\n        sql\n      }\n    }];\n  }\n}\n\n// Passed\nreturn [{\n  json: {\n    valid: true,\n    type: \"valid_sql\",\n    reason: \"\",\n    sql\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "0e8c6c9d-92d6-4217-8c32-24361b9841a1",
      "name": "Output Creation(Gemini)",
      "type": "@n8n/n8n-nodes-langchain.googleGemini",
      "onError": "continueErrorOutput",
      "position": [
        4064,
        -128
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "models/gemini-3.1-flash-lite",
          "cachedResultName": "models/gemini-3.1-flash-lite"
        },
        "options": {},
        "messages": {
          "values": [
            {
              "content": "=You are a helpful AI assistant.\n\nYour task is to answer the user's question based on the information provided.\n\nUser Question:\n{{ $('Extract Slack Message').first().json.question }}\n\nResult:\n{{ JSON.stringify($json, null, 2) }}\n\nInstructions:\n\n1. Respond in the SAME language as the user's question.\n2. If the question is not related to SQL, then return a generic reply and tell that you can provide responses related to Database only.\n3. If the result contains query data, answer the user's question using ONLY that data.\n4. If the result indicates that the request could not be completed (for example, \"valid\": false or an error/reason is present), do NOT invent an answer. Tell the user that there can be an issue with the question phrasing or the question may need some more information to provide appropriate answer.\n5. In case of failure:\n   - Explain politely that the requested information could not be retrieved.\n   - Do NOT mention SQL, databases, validation rules, prompts, or any internal implementation details.\n   - If possible, suggest how the user can rephrase the question.\n6. If the result is empty, state that no matching records were found.\n7. Never make up numbers or facts that are not present in the result.\n8. Format the response naturally using Slack markdown syntax (mrkdwn):\n   - Use *bold* for key labels, metric titles, or row headers.\n   - Use bullet points (\u2022) for short lists.\n   - For tabular or multi-column data, wrap it inside monospace code blocks (```) so columns align cleanly in Slack.\n9. If the result contains a single numeric value (COUNT, SUM, AVG, MIN, MAX), answer concisely in bold text (e.g., *Total active users:* 42).\n10. If there are more than 20 records, summarize them instead of listing every row.\n11. Never expose raw JSON or internal fields such as \"valid\", \"reason\", \"sql\", or \"confidence\".\n12. Return plain text using Slack markdown. Do not wrap the output in raw JSON."
            }
          ]
        },
        "builtInTools": {}
      },
      "credentials": {
        "googlePalmApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "5d447424-63bf-4aa6-a333-f86434bf28b0",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -192,
        -128
      ],
      "parameters": {
        "color": "#FFFFFF",
        "width": 480,
        "height": 320,
        "content": "Section 1: Receive Slack Request"
      },
      "typeVersion": 1
    },
    {
      "id": "df0011f5-d42f-44fa-8c77-81269ce1ac98",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        352,
        -128
      ],
      "parameters": {
        "color": "#FCFCFC",
        "width": 864,
        "height": 320,
        "content": "Section 2: Data Preparation \nLoads table metadata from Google Sheets, retrieves the corresponding PostgreSQL schema, and combines both into structured context for the AI model."
      },
      "typeVersion": 1
    },
    {
      "id": "09efa187-ef53-4332-aed7-abf54d8ff28a",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1248,
        -128
      ],
      "parameters": {
        "color": "#FCFCFC",
        "width": 544,
        "height": 320,
        "content": "Section 3: Generate SQL using AI\n"
      },
      "typeVersion": 1
    },
    {
      "id": "d253008a-9ea6-4772-a91e-100bd7f54f7b",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1824,
        -352
      ],
      "parameters": {
        "color": "#FCFCFC",
        "width": 784,
        "height": 544,
        "content": "Section 4: Validates the generated SQL to ensure it is a single safe read-only query. If validation fails, the AI is given one opportunity to regenerate the SQL before returning an error message.\n"
      },
      "typeVersion": 1
    },
    {
      "id": "70a7fdae-9b8a-4fb6-a344-5e2f92ae2ac5",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3520,
        -352
      ],
      "parameters": {
        "color": "#FFFFFF",
        "width": 864,
        "height": 544,
        "content": "Section 6: Executes validated SQL against PostgreSQL, formats the database results into natural language by LLM."
      },
      "typeVersion": 1
    },
    {
      "id": "3b6fdeb3-38dd-4027-a4d8-03f38d8e7c32",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1040,
        -800
      ],
      "parameters": {
        "width": 624,
        "height": 1328,
        "content": "## Query PostgreSQL from Slack using Google Sheets metadata and Gemini\n\nThis workflow lets Slack users ask natural-language questions about a PostgreSQL database, uses Google Sheets metadata plus live schema data to generate and validate a safe read-only SQL query with Google Gemini, executes it, and replies in Slack with a formatted answer.\n\nRequirements\n1. Triggers when the Slack app is mentioned in a channel.\n2. Extracts the user\u2019s question text, loads table definitions from Google Sheets, and builds a list of allowed tables.\n3. Queries PostgreSQL system catalogs for the selected tables\u2019 columns and keys, then combines that with the Google Sheets descriptions into a structured schema context.\n4. Sends the question and schema context to Google Gemini to generate a single read-only SQL query in JSON.\n5. Validates the generated SQL to ensure it is a single SELECT-only query, and if it fails, asks Google Gemini once to regenerate and revalidates.\n6. Executes the validated SQL against PostgreSQL and packages the rows into a results object.\n7. Uses Google Gemini to turn the query results into a Slack-formatted response (or a polite failure message) and posts it back to the originating Slack channel.\n\nHow it works\n1. Receives a question from Slack.\n2. Loads the relevant database metadata.\n3. Uses AI to generate a read-only SQL query.\n4. Validates the SQL for safety.\n5. Executes valid queries.\n6. Formats the results into natural language.\n7. Sends the response back to Slack.\n\nSetup\n1. Create and install a Slack App with a bot user, enable Event Subscriptions for app mentions, add scopes like chat:write and app_mentions:read, then connect the Slack bot token in n8n and set the target channel in the trigger and reply steps.\n2. Add PostgreSQL credentials in n8n for the database you want to query.\n3. Create a Google Sheet with table metadata (at minimum \u201cTable Name\u201d and \u201cDescription\u201d, plus any optional fields used), connect Google Sheets credentials, and set the document and sheet IDs.\n4. Add a Google Gemini (PaLM) API credential and select the model for the SQL-generation step.\n5. Publish the workflow and copy the Slack Trigger webhook URL into your Slack App\u2019s Event Subscriptions settings, then invite the bot to the channel you want to query from.\n\nMandatory Fields for Google Sheet\n1. \\# \n2. Table Name\n3. Description\n\n"
      },
      "typeVersion": 1
    },
    {
      "id": "cec8cc26-320b-4978-a4f9-58327e888bf3",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2640,
        -352
      ],
      "parameters": {
        "color": "#FCFCFC",
        "width": 848,
        "height": 544,
        "content": "Section 5: Recheck the failed SQL query with LLM and revalidating it to ensure single safe read-only query. If validation fails here, the error is sent directly to the output LLM.\n"
      },
      "typeVersion": 1
    },
    {
      "id": "d44b149d-f09b-400c-867c-95fb5d8d4d0b",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4432,
        -352
      ],
      "parameters": {
        "color": "#FFFFFF",
        "width": 832,
        "height": 544,
        "content": "Section 7: Extracts the LLM response and sends the final result to the slack or if LLM throws an error anywhere, a generic message is sent."
      },
      "typeVersion": 1
    },
    {
      "id": "dc311153-6773-40f6-bcab-7a8a2631180d",
      "name": "Prepare Table List",
      "type": "n8n-nodes-base.code",
      "position": [
        624,
        32
      ],
      "parameters": {
        "jsCode": "const tables = [\n  ...new Set(\n    $input.all().map(item => item.json[\"Table Name\"])\n  )\n]\n.map(table => `'${table}'`)\n.join(\",\");\n\nreturn [\n  {\n    json: {\n      tables\n    }\n  }\n];"
      },
      "typeVersion": 2
    },
    {
      "id": "e89e7c72-b66b-4dac-b328-28f8fa7534dd",
      "name": "Build AI Schema",
      "type": "n8n-nodes-base.code",
      "position": [
        1072,
        32
      ],
      "parameters": {
        "jsCode": "const sheet = $('Load Table Definitions').all();\nconst schema = $('Load Database Schema').all();\n\nconst result = {};\n\n// Step 1: Build table metadata from Google Sheet\nfor (const item of sheet) {\n  const row = item.json;\n  const table = row[\"Table Name\"];\n\n  // Create table only once\n  if (!result[table]) {\n    result[table] = {\n      description: row[\"Description\"],\n      primary_key: row[\"Primary key\"],\n      schema: []\n    };\n  }\n}\n\n// Step 2: Add columns from database schema\nfor (const item of schema) {\n  const row = item.json;\n  const table = row.table_name;\n\n  if (!result[table]) continue;\n\n  result[table].schema.push({\n    name: row.column_name,\n    type: row.data_type,\n    is_primary_key: row.is_primary_key,\n    referenced_table: row.referenced_table,\n    referenced_column: row.referenced_column,\n    enum_values: row.enum_values\n  });\n}\n\nreturn [{\n  json: result\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "c24612f4-6c82-471c-91bf-0f5b3373192a",
      "name": "Parse LLM Result",
      "type": "n8n-nodes-base.code",
      "position": [
        1648,
        -48
      ],
      "parameters": {
        "jsCode": "const text = $json.content.parts[0].text;\n\nreturn [\n  {\n    json: JSON.parse(text)\n  }\n];"
      },
      "typeVersion": 2
    },
    {
      "id": "e416d212-ecee-47b4-8e99-d807c2207e2e",
      "name": "Extract Slack Message",
      "type": "n8n-nodes-base.set",
      "position": [
        96,
        32
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "2b313304-df30-4b93-ab5f-df807589611e",
              "name": "question",
              "type": "string",
              "value": "={{ $json.text.replace(/<@[A-Z0-9]+>/g, '').trim() }}"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "88227266-41cb-42e9-bd29-117f499bb946",
      "name": "Load Table Definitions",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        400,
        32
      ],
      "parameters": {
        "sheetName": {
          "__rl": true,
          "mode": "id",
          "value": "",
          "cachedResultUrl": "",
          "cachedResultName": ""
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "",
          "cachedResultUrl": "",
          "cachedResultName": ""
        }
      },
      "typeVersion": 4.7
    },
    {
      "id": "ddfb14d3-0f83-49a9-a42f-f01012284498",
      "name": "Load Database Schema",
      "type": "n8n-nodes-base.postgres",
      "position": [
        848,
        32
      ],
      "parameters": {
        "query": "SELECT\n    c.table_name,\n    c.column_name,\n    c.data_type,\n\n    -- Is Primary Key\n    CASE\n        WHEN tc.constraint_type = 'PRIMARY KEY' THEN TRUE\n        ELSE FALSE\n    END AS is_primary_key,\n\n    -- Foreign Key Information\n    ccu.table_name AS referenced_table,\n    ccu.column_name AS referenced_column,\n\n    -- Enum Values (NULL for non-enum columns)\n    (\n        SELECT array_agg(e.enumlabel ORDER BY e.enumsortorder)\n        FROM pg_type t\n        JOIN pg_enum e\n            ON t.oid = e.enumtypid\n        WHERE t.typname = c.udt_name\n    ) AS enum_values\n\nFROM information_schema.columns c\n\nLEFT JOIN information_schema.key_column_usage kcu\n    ON c.table_schema = kcu.table_schema\n   AND c.table_name = kcu.table_name\n   AND c.column_name = kcu.column_name\n\nLEFT JOIN information_schema.table_constraints tc\n    ON tc.constraint_name = kcu.constraint_name\n   AND tc.table_schema = kcu.table_schema\n\nLEFT JOIN information_schema.constraint_column_usage ccu\n    ON ccu.constraint_name = tc.constraint_name\n   AND ccu.table_schema = tc.table_schema\n\nWHERE c.table_name IN ({{ $json.tables }})\n\nORDER BY c.table_name, c.ordinal_position;",
        "options": {},
        "operation": "executeQuery"
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.6
    },
    {
      "id": "4fa94d1a-0b9d-4199-a5f9-c7945b1c6126",
      "name": "Generate SQL",
      "type": "@n8n/n8n-nodes-langchain.googleGemini",
      "onError": "continueErrorOutput",
      "position": [
        1296,
        32
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "",
          "cachedResultUrl": "",
          "cachedResultName": ""
        },
        "options": {},
        "messages": {
          "values": [
            {
              "content": "=You are an expert PostgreSQL SQL engineer.\n\nYour task is to convert a user's natural language question into a single read-only SQL query using ONLY the schema provided.\n\nDatabase Schema:\n{{ JSON.stringify($('Build AI Schema').first().json, null, 2) }}\n\nUser Question:\n{{ $('Extract Slack Message').first().json.question }}\n\nCurrent Timestamp:\n{{ $now }}\n\nRules:\n1. First determine whether the user's question is asking for database information. If the user is greeting, making casual conversation, asking unrelated questions, or the request does not require database data:\n- Leave the sql field empty.\n- Explain that the request is not a database query.\n- Set confidence accordingly.\n2. If the user's request would require revealing database schema, table names, column names, relationships, database structure, or other internal database metadata:\n- Do not generate SQL.\n- Leave the sql field empty.\n- Explain that you cannot provide database structure details.\n- Ask the user to ask a specific question about the data they need.\n- Do not reveal schema details, table names, column names, or internal database information.\n- Set confidence accordingly.\n3. Use ONLY the tables and columns present in the provided schema.\n4. Generate ONLY one SELECT statement.\n5. Never generate INSERT, UPDATE, DELETE, DROP, ALTER, CREATE, TRUNCATE, GRANT, REVOKE, COPY or DO statements.\n6. If the result can contain many rows and the user has not specified a limit, add LIMIT 1000.\n7. If the query uses aggregate functions like COUNT, SUM, AVG, MIN or MAX, do NOT add LIMIT.\n8. Prefer a single SQL query using JOINs, subqueries or CTEs whenever possible.\n9. You have the database schema with you as well, if needed use joins to appropriately get the values of uuid from other table if it can be but dont pre-assume.\n10. Do not provide sql which can result in showing sensitive uuids.\n11. When joining a referenced table, return only the columns that are relevant to answering the user's question. Prefer human-readable information (such as names or titles) over internal identifiers. Do not return UUIDs, foreign keys, or technical fields unless the user explicitly requests them.\n12. If the request genuinely cannot be answered with a single SQL query, leave the sql field empty and explain why.\n13. If the schema does not contain enough information, leave the sql field empty and explain why.\n14. Use PostgreSQL syntax only.\n15. Do not wrap the SQL inside markdown or code fences.\n16. Return ONLY valid JSON.\n17. It is mandatory to provide confidence of your answer on the scale of 0.0 to 1.0.\n\nOutput:\n\n{\n  \"sql\": \"\",\n  \"explanation\": \"\",\n  \"confidence\": 0.0\n}"
            }
          ]
        },
        "jsonOutput": true,
        "builtInTools": {}
      },
      "typeVersion": 1.2
    },
    {
      "id": "eb916ada-b0a2-4cf4-81fd-4d4c4e361741",
      "name": "SQL Valid?",
      "type": "n8n-nodes-base.if",
      "position": [
        2096,
        -144
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cf4c612c-46e5-45af-886b-9a04a8af6dd9",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.valid }}",
              "rightValue": false
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "7a386caa-e65d-4871-aad1-a4e82c4e1e7e",
      "name": "Determine Failure Cause",
      "type": "n8n-nodes-base.if",
      "position": [
        2320,
        -256
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c4ad85ff-1020-42cd-aa53-6805306c0871",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.type }}",
              "rightValue": "=no_sql"
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "0638cf36-2c74-4c09-9a4f-6391cae0d631",
      "name": "Regenerate SQL",
      "type": "@n8n/n8n-nodes-langchain.googleGemini",
      "onError": "continueErrorOutput",
      "position": [
        2672,
        -32
      ],
      "parameters": {
        "modelId": {
          "__rl": true,
          "mode": "list",
          "value": "models/gemini-3.1-flash-lite",
          "cachedResultName": "models/gemini-3.1-flash-lite"
        },
        "options": {},
        "messages": {
          "values": [
            {
              "content": "=You generated the following SQL.\n\nQuestion:\n{{ $('Extract Slack Message').item.json.question }}\n\nSchema:\n{{ JSON.stringify($('Build AI Schema').first().json, null, 2) }}\n\nSQL:\n{{ $('Validate LLM Output').item.json.sql }}\n\nValidation failed.\n\nReason:\n{{ $('Validate LLM Output').item.json.reason }}\n\n\nFix ONLY the SQL, but make sure of the following things-\n1. Never generate INSERT, UPDATE, DELETE, DROP, ALTER, CREATE, TRUNCATE, GRANT, REVOKE, COPY or DO statements.\n2. If the result can contain many rows and the user has not specified a limit, add LIMIT 1000.\n3. If the query uses aggregate functions like COUNT, SUM, AVG, MIN or MAX, do NOT add LIMIT.\n4. Prefer a single SQL query using JOINs, subqueries or CTEs whenever possible.\n5. Do not provide sql which can result in showing sensitive uuids.\n\nReturn JSON\n\n{\n    \"sql\":\"...\"\n}"
            }
          ]
        },
        "jsonOutput": true,
        "builtInTools": {}
      },
      "credentials": {
        "googlePalmApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "a9fae968-d1fa-44fb-835a-081a84088d45",
      "name": "Parse AI Response (Retry)",
      "type": "n8n-nodes-base.code",
      "position": [
        2992,
        -96
      ],
      "parameters": {
        "jsCode": "const text = $json.content.parts[0].text;\n\nreturn [\n  {\n    json: JSON.parse(text)\n  }\n];"
      },
      "typeVersion": 2
    },
    {
      "id": "daa9e1d1-8bdf-44f5-a90e-fce0f534bde8",
      "name": "Validate SQL (Retry)",
      "type": "n8n-nodes-base.code",
      "position": [
        3168,
        -96
      ],
      "parameters": {
        "jsCode": "let sql = $json.sql.trim();\n\n// Convert to uppercase for validation only\nconst upperSql = sql.toUpperCase();\n\n// Forbidden keywords\nconst forbidden = [\n  \"INSERT\",\n  \"UPDATE\",\n  \"DELETE\",\n  \"DROP\",\n  \"ALTER\",\n  \"CREATE\",\n  \"TRUNCATE\",\n  \"GRANT\",\n  \"REVOKE\",\n  \"COPY\",\n  \"EXECUTE\",\n  \"DO\"\n];\n\n// 1. Must start with SELECT\nif (!upperSql.startsWith(\"SELECT\")) {\n  return [{\n    json: {\n      valid: false,\n      reason: \"Only SELECT queries are allowed.\",\n      sql\n    }\n  }];\n}\n\n// 2. Reject multiple SQL statements\nconst semicolons = (sql.match(/;/g) || []).length;\nif (semicolons > 1) {\n  return [{\n    json: {\n      valid: false,\n      reason: \"Multiple SQL statements are not allowed.\",\n      sql\n    }\n  }];\n}\n\n// 3. Check forbidden keywords\nfor (const keyword of forbidden) {\n  const regex = new RegExp(`\\\\b${keyword}\\\\b`, 'i');\n\n  if (regex.test(sql)) {\n    return [{\n      json: {\n        valid: false,\n        reason: `Forbidden keyword detected: ${keyword}`,\n        sql\n      }\n    }];\n  }\n}\n\n// Passed all checks\nreturn [{\n  json: {\n    valid: true,\n    reason: \"\",\n    sql\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "37b429e0-ef6e-424c-acd1-1203564e4922",
      "name": "SQL Valid? (Retry)",
      "type": "n8n-nodes-base.if",
      "position": [
        3344,
        -96
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cf4c612c-46e5-45af-886b-9a04a8af6dd9",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.valid }}",
              "rightValue": false
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "7f547175-49b7-4bfa-9f0a-d816fa2dfe13",
      "name": "Execute SQL Query",
      "type": "n8n-nodes-base.postgres",
      "onError": "continueRegularOutput",
      "position": [
        3616,
        -192
      ],
      "parameters": {
        "query": "{{ $json.sql }}",
        "options": {},
        "operation": "executeQuery"
      },
      "typeVersion": 2.6,
      "alwaysOutputData": true
    },
    {
      "id": "d1aa9752-b917-4b25-b48b-f5d95d2f4f67",
      "name": "Format Query Result",
      "type": "n8n-nodes-base.code",
      "position": [
        3840,
        -192
      ],
      "parameters": {
        "jsCode": "return [\n  {\n    json: {\n      results: $input.all().map(item => item.json)\n    }\n  }\n];"
      },
      "typeVersion": 2
    },
    {
      "id": "b9d386d8-6604-4e5b-8749-a7f6376e0e24",
      "name": "Prepare Slack Response",
      "type": "n8n-nodes-base.code",
      "onError": "continueRegularOutput",
      "position": [
        4624,
        -144
      ],
      "parameters": {
        "jsCode": "const text = $json.content.parts[0].text;\n\nreturn [\n  {\n    json: {\n      blocks: [\n        {\n          type: \"section\",\n          text: {\n            type: \"mrkdwn\",\n            text: text\n          }\n        }\n      ]\n    }\n  }\n];"
      },
      "typeVersion": 2
    },
    {
      "id": "35e1901c-acfb-4a80-bebc-aec306eb253f",
      "name": "Handle Failure Response",
      "type": "n8n-nodes-base.set",
      "position": [
        4624,
        0
      ],
      "parameters": {
        "mode": "raw",
        "options": {},
        "jsonOutput": "{\n  \"blocks\": [\n    {\n      \"type\": \"section\",\n      \"text\": {\n        \"type\": \"mrkdwn\",\n        \"text\": \"LLM is down currently, please try again after a while!\"\n      }\n    }\n  ]\n}"
      },
      "typeVersion": 3.4
    },
    {
      "id": "0c202ff2-0e07-4122-836a-29ac4ce61972",
      "name": "Send Slack Reply",
      "type": "n8n-nodes-base.slack",
      "position": [
        4928,
        -112
      ],
      "parameters": {
        "text": "={{ $json.blocks[0].text.text }}",
        "select": "channel",
        "blocksUi": "={{$json.blocks}}",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": "C0BJYUPEU1L",
          "cachedResultName": "db-channel"
        },
        "messageType": "block",
        "otherOptions": {
          "includeLinkToWorkflow": false
        }
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.5
    },
    {
      "id": "83a8cc1d-9c89-49bc-8178-0c15cd8fb6ee",
      "name": "Sticky Note8",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -192,
        -624
      ],
      "parameters": {
        "color": "#FFFFFF",
        "width": 480,
        "height": 480,
        "content": "## Slack App Setup Required\n\nBefore running this workflow:\n\n1. In https://api.slack.com/, create a Slack App\n2. In App Home -> Add Bot User & install app \n3. Turn On Event Subscriptions - put webhook URL\n4. Add required OAuth scopes:\n   \u2022 chat:write\n   \u2022 channels:history\n   \u2022 groups:history (optional)\n   \u2022 app_mentions:read (optional)\n5. In OAuth & Security, copy Bot Token to n8n credentials\n6. Invite bot to your channel\n\nThe Slack Trigger Webhook URL is provided by the \"Slack Trigger\" node. Use the Production URL after publishing the workflow.Then use @bot_name and query in the slack group.\n"
      },
      "typeVersion": 1
    }
  ],
  "active": true,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "df75ae27-51a0-46f9-bf34-de779ef17ac0",
  "nodeGroups": [],
  "connections": {
    "SQL Valid?": {
      "main": [
        [
          {
            "node": "Execute SQL Query",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Determine Failure Cause",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate SQL": {
      "main": [
        [
          {
            "node": "Parse LLM Result",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Handle Failure Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Slack Trigger": {
      "main": [
        [
          {
            "node": "Extract Slack Message",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Regenerate SQL": {
      "main": [
        [
          {
            "node": "Parse AI Response (Retry)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Handle Failure Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build AI Schema": {
      "main": [
        [
          {
            "node": "Generate SQL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse LLM Result": {
      "main": [
        [
          {
            "node": "Validate LLM Output",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Execute SQL Query": {
      "main": [
        [
          {
            "node": "Format Query Result",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Table List": {
      "main": [
        [
          {
            "node": "Load Database Schema",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "SQL Valid? (Retry)": {
      "main": [
        [
          {
            "node": "Execute SQL Query",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Output Creation(Gemini)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format Query Result": {
      "main": [
        [
          {
            "node": "Output Creation(Gemini)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate LLM Output": {
      "main": [
        [
          {
            "node": "SQL Valid?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Database Schema": {
      "main": [
        [
          {
            "node": "Build AI Schema",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate SQL (Retry)": {
      "main": [
        [
          {
            "node": "SQL Valid? (Retry)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Slack Message": {
      "main": [
        [
          {
            "node": "Load Table Definitions",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Load Table Definitions": {
      "main": [
        [
          {
            "node": "Prepare Table List",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Slack Response": {
      "main": [
        [
          {
            "node": "Send Slack Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Determine Failure Cause": {
      "main": [
        [
          {
            "node": "Output Creation(Gemini)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Regenerate SQL",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Handle Failure Response": {
      "main": [
        [
          {
            "node": "Send Slack Reply",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Output Creation(Gemini)": {
      "main": [
        [
          {
            "node": "Prepare Slack Response",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Handle Failure Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse AI Response (Retry)": {
      "main": [
        [
          {
            "node": "Validate SQL (Retry)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

This workflow lets Slack users ask natural-language questions about a PostgreSQL database, uses Google Sheets metadata plus live schema data to generate and validate a safe read-only SQL query with Google Gemini, executes it, and replies in Slack with a formatted answer.…

Source: https://n8n.io/workflows/17442/ — original creator credit. Request a take-down →

More Data & Sheets workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Data & Sheets

This n8n workflow automates the transformation of spreadsheet data into professional charts and graphs using AI-driven analysis. Triggered via Slack, it processes uploaded files (Excel, CSV, Google Sh

Agent, Postgres, HTTP Request +8
Data & Sheets

This workflow acts as a junior finance research analyst for a UK boutique M&A or corporate finance team. It listens for Slack messages, classifies the request, gathers company or market data, and prod

HTTP Request, Google Drive, Google Docs +5
Data & Sheets

aula-00-mapa-do-n8n. Uses emailReadImap, stopAndError, httpRequest, graphql. Event-driven trigger; 46 nodes.

Email Read Imap, Stop And Error, HTTP Request +14
Data & Sheets

Scheduled processes retrieve customer feedback from multiple channels. The system performs sentiment analysis to classify tone, then uses OpenAI models to extract themes, topics, and urgency indicator

HTTP Request, Lm Chat Azure Open Ai, Sentiment Analysis +5
Data & Sheets

This scheduled workflow ingests tender records, checks Google Sheets and Supabase for duplicates, uses Google Gemini to generate an eligibility summary, match score, and bid checklist, then stores the

Google Sheets, Supabase, Google Gemini +2