From 5ebe1ab9b5f079ce4584e871460225a64e2a275a Mon Sep 17 00:00:00 2001
From: sjjian <921465802@qq.com>
Date: Wed, 11 Mar 2026 10:10:41 +0000
Subject: [PATCH 01/25] feat(postgres): implement PostgresJdbcUrlParser for PG
JDBC URL parsing
Add PostgresJdbcUrlParser that implements JdbcUrlParser interface to
parse PostgreSQL JDBC URLs. Uses string splitting (not regex) for
parsing to reduce complexity.
Features:
- Parse standard URL: jdbc:postgresql://host:port/database
- Extract schema from currentSchema parameter
- Support default port 5432 when not specified
- Parse multiple query parameters
Includes comprehensive unit tests covering:
- Standard URL parsing
- URL without port (default 5432)
- URL without parameters
- URL with multiple parameters
- currentSchema extraction
- Special character handling
- Null/invalid input handling
Ref: AC-001.4, AC-001.5
---
.../postgres/PostgresJdbcUrlParser.java | 206 ++++++++++++++
.../postgres/PostgresJdbcUrlParserTest.java | 251 ++++++++++++++++++
2 files changed, 457 insertions(+)
create mode 100644 server/plugins/connect-plugin-postgres/src/main/java/com/oceanbase/odc/plugin/connect/postgres/PostgresJdbcUrlParser.java
create mode 100644 server/plugins/connect-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/connect/postgres/PostgresJdbcUrlParserTest.java
diff --git a/server/plugins/connect-plugin-postgres/src/main/java/com/oceanbase/odc/plugin/connect/postgres/PostgresJdbcUrlParser.java b/server/plugins/connect-plugin-postgres/src/main/java/com/oceanbase/odc/plugin/connect/postgres/PostgresJdbcUrlParser.java
new file mode 100644
index 0000000000..32f19af154
--- /dev/null
+++ b/server/plugins/connect-plugin-postgres/src/main/java/com/oceanbase/odc/plugin/connect/postgres/PostgresJdbcUrlParser.java
@@ -0,0 +1,206 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.plugin.connect.postgres;
+
+import java.sql.SQLException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import com.oceanbase.odc.plugin.connect.api.HostAddress;
+import com.oceanbase.odc.plugin.connect.api.JdbcUrlParser;
+
+import lombok.NonNull;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * PostgreSQL JDBC URL 解析器
+ *
+ *
+ *
+ * @param jdbcUrlParams 用户自定义的 JDBC URL 参数
+ * @return 包含默认参数的参数 Map
+ */
+ @Override
+ protected Map appendDefaultJdbcUrlParameters(Map jdbcUrlParams) {
+ if (jdbcUrlParams == null) {
+ jdbcUrlParams = new HashMap<>();
+ }
+ // 设置 ApplicationName 参数,用于标识连接来源
+ // 该参数会在 pg_stat_activity.application_name 中显示
+ if (!jdbcUrlParams.containsKey("ApplicationName")) {
+ jdbcUrlParams.put("ApplicationName", DEFAULT_APP_NAME);
+ }
+ return jdbcUrlParams;
+ }
+
}
diff --git a/server/plugins/connect-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/connect/postgres/PostgresConnectionExtensionTest.java b/server/plugins/connect-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/connect/postgres/PostgresConnectionExtensionTest.java
new file mode 100644
index 0000000000..2820a191e1
--- /dev/null
+++ b/server/plugins/connect-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/connect/postgres/PostgresConnectionExtensionTest.java
@@ -0,0 +1,265 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.plugin.connect.postgres;
+
+import java.sql.SQLException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import com.oceanbase.odc.core.shared.constant.OdcConstants;
+import com.oceanbase.odc.plugin.connect.api.HostAddress;
+import com.oceanbase.odc.plugin.connect.api.JdbcUrlParser;
+import com.oceanbase.odc.plugin.connect.model.JdbcUrlProperty;
+
+/**
+ * {@link PostgresConnectionExtension} 单元测试
+ *
+ *
+ * PostgreSQL 使用双引号包裹大小写敏感或包含特殊字符的标识符。 标识符内的双引号需要转义为双写双引号。
+ *
+ * @param identifier 标识符
+ * @return 转义后的标识符
+ */
+ private String escapeIdentifier(String identifier) {
+ if (identifier == null) {
+ return null;
+ }
+ // 双引号转义为双写双引号
+ String escaped = identifier.replace("\"", "\"\"");
+ return "\"" + escaped + "\"";
+ }
+
}
diff --git a/server/plugins/connect-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/connect/postgres/PostgresSessionExtensionTest.java b/server/plugins/connect-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/connect/postgres/PostgresSessionExtensionTest.java
new file mode 100644
index 0000000000..99a4718fd1
--- /dev/null
+++ b/server/plugins/connect-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/connect/postgres/PostgresSessionExtensionTest.java
@@ -0,0 +1,347 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.plugin.connect.postgres;
+
+import static org.mockito.Mockito.mock;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.springframework.jdbc.core.JdbcOperations;
+
+import com.oceanbase.odc.common.util.JdbcOperationsUtil;
+import com.oceanbase.odc.plugin.connect.model.DBClientInfo;
+
+/**
+ * {@link PostgresSessionExtension} 单元测试
+ *
+ *
+ * PostgreSQL 使用 SELECT current_schema()
+ */
+ @Test
+ public void testGetCurrentSchema_QueryFormat() {
+ // 应该是 "SELECT current_schema()"
+ // 对比 MySQL: "SELECT DATABASE()"
+ Assert.assertTrue(true); // Placeholder for integration test
+ }
+
+ // ==================== 综合边界测试 ====================
+
+ /**
+ * 测试用例:特殊连接 ID 字符处理
+ */
+ @Test
+ public void testKillQuerySql_SpecialCharacters() {
+ // 连接 ID 应该是数字,但测试输入处理
+ String connectionId = "12345";
+ String sql = extension.getKillQuerySql(connectionId);
+
+ // SQL 应该直接包含连接 ID(无额外引号)
+ Assert.assertTrue(sql.contains(connectionId));
+ }
+
+ /**
+ * 测试用例:空连接 ID 处理(不应抛出异常)
+ */
+ @Test
+ public void testKillQuerySql_EmptyConnectionId() {
+ String sql = extension.getKillQuerySql("");
+ Assert.assertEquals("SELECT pg_cancel_backend()", sql);
+ }
+
+ /**
+ * 测试用例:多个终止查询调用结果一致性
+ */
+ @Test
+ public void testGetKillQuerySql_Consistency() {
+ String connectionId = "999";
+
+ String sql1 = extension.getKillQuerySql(connectionId);
+ String sql2 = extension.getKillQuerySql(connectionId);
+
+ Assert.assertEquals(sql1, sql2);
+ }
+
+ /**
+ * 测试用例:多个终止会话调用结果一致性
+ */
+ @Test
+ public void testGetKillSessionSql_Consistency() {
+ String connectionId = "999";
+
+ String sql1 = extension.getKillSessionSql(connectionId);
+ String sql2 = extension.getKillSessionSql(connectionId);
+
+ Assert.assertEquals(sql1, sql2);
+ }
+}
From 3ce39bb145127452bc4238bdad417b917d772a8a Mon Sep 17 00:00:00 2001
From: sjjian <921465802@qq.com>
Date: Wed, 11 Mar 2026 11:03:50 +0000
Subject: [PATCH 04/25] feat(postgres): implement PostgreSqlSplitter for
PG-specific SQL splitting
---
.../core/sql/split/PostgreSqlSplitter.java | 420 ++++++++++++
.../sql/split/PostgreSqlSplitterTest.java | 618 ++++++++++++++++++
2 files changed, 1038 insertions(+)
create mode 100644 server/odc-core/src/main/java/com/oceanbase/odc/core/sql/split/PostgreSqlSplitter.java
create mode 100644 server/odc-core/src/test/java/com/oceanbase/odc/core/sql/split/PostgreSqlSplitterTest.java
diff --git a/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/split/PostgreSqlSplitter.java b/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/split/PostgreSqlSplitter.java
new file mode 100644
index 0000000000..e1e2009699
--- /dev/null
+++ b/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/split/PostgreSqlSplitter.java
@@ -0,0 +1,420 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.core.sql.split;
+
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.NoSuchElementException;
+
+import com.oceanbase.odc.common.util.StringUtils;
+import com.oceanbase.odc.core.shared.PreConditions;
+
+/**
+ * PostgreSQL SQL script splitter.
+ *
+ *
+ * Features:
+ *
+ *
Supports dollar-quoting: $$...$$ and $tag$...$tag$
+ *
Supports E-string: E'...' with backslash escapes
+ *
Supports nested block comments (PG specific)
+ *
Supports double-quoted identifiers: "column_name"
+ *
Supports single-quoted strings with doubling escape: 'don''t'
+ *
+ *
+ *
+ *
+ * Dollar-quoting is a PostgreSQL-specific feature that allows string literals to contain any
+ * characters without escaping, including newlines, quotes, and semicolons. This is commonly used in
+ * function and procedure definitions.
+ *
+ */
+public class PostgreSqlSplitter {
+
+ private static final String DEFAULT_DELIMITER = ";";
+
+ private final String delimiter;
+
+ public PostgreSqlSplitter() {
+ this(DEFAULT_DELIMITER);
+ }
+
+ public PostgreSqlSplitter(String delimiter) {
+ this.delimiter = StringUtils.isBlank(delimiter) ? DEFAULT_DELIMITER : delimiter;
+ }
+
+ /**
+ * Split SQL script into individual statements.
+ *
+ * @param sql the SQL script to split
+ * @return list of statements with their offsets
+ */
+ public List split(String sql) {
+ if (StringUtils.isBlank(sql)) {
+ return new ArrayList<>();
+ }
+ PreConditions.notBlank(delimiter, "delimiter", "Empty or blank delimiter is not allowed");
+
+ final int n = sql.length();
+ final List out = new ArrayList<>();
+
+ // Parser states
+ boolean inSingleQuote = false; // Single-quoted string '...'
+ boolean inEString = false; // E-string E'...' (backslash escapes)
+ boolean inDoubleQuote = false; // Double-quoted identifier "..."
+ boolean inLineComment = false; // Line comment --
+ boolean inBlockComment = false; // Block comment /* ... */
+ boolean inDollarQuote = false; // Dollar-quoting $$...$$ or $tag$...$tag$
+
+ // Dollar-quoting state
+ String currentDollarTag = null; // Current dollar tag (empty string for $$)
+
+ // Block comment nesting depth (PostgreSQL supports nested block comments)
+ int blockCommentDepth = 0;
+
+ // E-string backslash escape state
+ boolean inBackslashEscape = false;
+
+ int stmtStart = 0;
+ int i = 0;
+
+ while (i < n) {
+ char c = sql.charAt(i);
+
+ // === State: Line Comment ===
+ if (inLineComment) {
+ if (c == '\n') {
+ inLineComment = false;
+ }
+ i++;
+ continue;
+ }
+
+ // === State: Block Comment (with nesting support) ===
+ if (inBlockComment) {
+ if (c == '/' && i + 1 < n && sql.charAt(i + 1) == '*') {
+ // Nested block comment start
+ blockCommentDepth++;
+ i += 2;
+ continue;
+ }
+ if (c == '*' && i + 1 < n && sql.charAt(i + 1) == '/') {
+ blockCommentDepth--;
+ if (blockCommentDepth == 0) {
+ inBlockComment = false;
+ }
+ i += 2;
+ continue;
+ }
+ i++;
+ continue;
+ }
+
+ // === State: Single-quoted String ===
+ if (inSingleQuote) {
+ if (c == '\'') {
+ if (i + 1 < n && sql.charAt(i + 1) == '\'') {
+ // Escaped single quote by doubling ''
+ i += 2;
+ continue;
+ }
+ // End of single-quoted string
+ inSingleQuote = false;
+ }
+ i++;
+ continue;
+ }
+
+ // === State: E-string (PostgreSQL extended string with backslash escapes) ===
+ if (inEString) {
+ if (inBackslashEscape) {
+ // After backslash, next character is escaped (including another backslash)
+ inBackslashEscape = false;
+ i++;
+ continue;
+ }
+ if (c == '\\') {
+ inBackslashEscape = true;
+ i++;
+ continue;
+ }
+ if (c == '\'') {
+ // Check for doubled single quote '' (also valid in E-string)
+ if (i + 1 < n && sql.charAt(i + 1) == '\'') {
+ i += 2;
+ continue;
+ }
+ // End of E-string
+ inEString = false;
+ inBackslashEscape = false;
+ }
+ i++;
+ continue;
+ }
+
+ // === State: Double-quoted Identifier ===
+ if (inDoubleQuote) {
+ if (c == '"') {
+ if (i + 1 < n && sql.charAt(i + 1) == '"') {
+ // Escaped double quote by doubling ""
+ i += 2;
+ continue;
+ }
+ // End of double-quoted identifier
+ inDoubleQuote = false;
+ }
+ i++;
+ continue;
+ }
+
+ // === State: Dollar-quoting ===
+ if (inDollarQuote) {
+ if (c == '$') {
+ String endTag = matchDollarTag(sql, i);
+ if (endTag != null && endTag.equals(currentDollarTag)) {
+ // Found matching end tag
+ inDollarQuote = false;
+ currentDollarTag = null;
+ i += endTag.length() + 2; // Skip $tag$
+ continue;
+ }
+ }
+ // Any character inside dollar-quoting (including semicolons) is literal
+ i++;
+ continue;
+ }
+
+ // === Normal State ===
+
+ // Line comment start
+ if (c == '-' && i + 1 < n && sql.charAt(i + 1) == '-') {
+ inLineComment = true;
+ i += 2;
+ continue;
+ }
+
+ // Block comment start
+ if (c == '/' && i + 1 < n && sql.charAt(i + 1) == '*') {
+ inBlockComment = true;
+ blockCommentDepth = 1;
+ i += 2;
+ continue;
+ }
+
+ // Single-quoted string start
+ if (c == '\'') {
+ inSingleQuote = true;
+ i++;
+ continue;
+ }
+
+ // E-string start: E' or e'
+ if ((c == 'E' || c == 'e') && i + 1 < n && sql.charAt(i + 1) == '\'') {
+ inEString = true;
+ i += 2;
+ continue;
+ }
+
+ // Double-quoted identifier start
+ if (c == '"') {
+ inDoubleQuote = true;
+ i++;
+ continue;
+ }
+
+ // Dollar-quoting start
+ if (c == '$') {
+ String tag = matchDollarTag(sql, i);
+ if (tag != null) {
+ inDollarQuote = true;
+ currentDollarTag = tag;
+ i += tag.length() + 2; // Skip $tag$
+ continue;
+ }
+ // Standalone $ (not a dollar-quoting delimiter), continue
+ i++;
+ continue;
+ }
+
+ // Delimiter check - split happens here
+ if (isPrefix(sql, i, delimiter)) {
+ addIfNotBlank(out, sql, stmtStart, i + delimiter.length());
+ i += delimiter.length();
+ stmtStart = i;
+ continue;
+ }
+
+ i++;
+ }
+
+ // Add remaining statement
+ addIfNotBlank(out, sql, stmtStart, n);
+ return out;
+ }
+
+ /**
+ * Match a dollar-quoting tag at the given position.
+ *
+ *
+ * Dollar-quoting format: $tag$ where tag is optional. If tag is empty, it's just $$. Tag
+ * characters: letters, digits, underscores, but first character cannot be a digit.
+ *
+ *
+ * @param sql the SQL string
+ * @param pos position where '$' is found
+ * @return the tag string (empty string for $$), or null if not a valid dollar tag
+ */
+ private String matchDollarTag(String sql, int pos) {
+ final int n = sql.length();
+
+ // Current character must be '$'
+ if (pos >= n || sql.charAt(pos) != '$') {
+ return null;
+ }
+
+ // Scan for the closing '$'
+ int j = pos + 1;
+ while (j < n) {
+ char c = sql.charAt(j);
+ if (c == '$') {
+ // Found closing $
+ break;
+ }
+ if (!isDollarTagChar(c, j == pos + 1)) {
+ // Invalid tag character
+ return null;
+ }
+ j++;
+ }
+
+ // Must find closing $
+ if (j >= n || sql.charAt(j) != '$') {
+ return null;
+ }
+
+ // Tag is the content between the two $ signs
+ return sql.substring(pos + 1, j);
+ }
+
+ /**
+ * Check if character is valid for dollar tag.
+ *
+ * @param c the character to check
+ * @param isFirst true if this is the first character of the tag
+ * @return true if valid dollar tag character
+ */
+ private boolean isDollarTagChar(char c, boolean isFirst) {
+ if (Character.isLetter(c) || c == '_') {
+ return true;
+ }
+ if (!isFirst && Character.isDigit(c)) {
+ return true;
+ }
+ return false;
+ }
+
+ private static boolean isPrefix(String s, int offset, String prefix) {
+ if (offset + prefix.length() > s.length()) {
+ return false;
+ }
+ return s.startsWith(prefix, offset);
+ }
+
+ private static void addIfNotBlank(List out, String sql, int start, int end) {
+ if (end <= start) {
+ return;
+ }
+ String segment = sql.substring(start, end);
+ if (StringUtils.isBlank(segment)) {
+ return;
+ }
+ out.add(new OffsetString(start, segment));
+ }
+
+ /**
+ * Create an iterator for streaming SQL statement parsing.
+ *
+ * @param input the input stream
+ * @param charset the character set
+ * @param delimiter the statement delimiter
+ * @return an iterator over SQL statements
+ */
+ public static SqlStatementIterator iterator(InputStream input, Charset charset, String delimiter) {
+ PreConditions.notNull(input, "input");
+ PreConditions.notNull(charset, "charset");
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter(delimiter);
+ String sql = readAll(input, charset);
+ List stmts = splitter.split(sql);
+ return new ListSqlStatementIterator(stmts);
+ }
+
+ private static String readAll(InputStream input, Charset charset) {
+ try (Reader reader = new InputStreamReader(input, charset)) {
+ StringBuilder sb = new StringBuilder();
+ char[] buf = new char[4096];
+ int len;
+ while ((len = reader.read(buf)) >= 0) {
+ sb.append(buf, 0, len);
+ }
+ return sb.toString();
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to read sql input", e);
+ }
+ }
+
+ /**
+ * Iterator implementation based on a pre-split list.
+ */
+ private static class ListSqlStatementIterator implements SqlStatementIterator {
+
+ private final Iterator it;
+ private OffsetString current;
+ private long iteratedBytes = 0;
+
+ private ListSqlStatementIterator(List stmts) {
+ this.it = stmts.iterator();
+ }
+
+ @Override
+ public boolean hasNext() {
+ if (current == null && it.hasNext()) {
+ current = it.next();
+ iteratedBytes = Math.max(iteratedBytes, (long) current.getOffset() + current.getStr().length());
+ }
+ return current != null;
+ }
+
+ @Override
+ public OffsetString next() {
+ if (!hasNext()) {
+ throw new NoSuchElementException("No more available sql.");
+ }
+ OffsetString next = current;
+ current = null;
+ return next;
+ }
+
+ @Override
+ public long iteratedBytes() {
+ return iteratedBytes;
+ }
+ }
+}
diff --git a/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/split/PostgreSqlSplitterTest.java b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/split/PostgreSqlSplitterTest.java
new file mode 100644
index 0000000000..e41a8a1486
--- /dev/null
+++ b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/split/PostgreSqlSplitterTest.java
@@ -0,0 +1,618 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.core.sql.split;
+
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link PostgreSqlSplitter}.
+ *
+ *
+ * Test categories:
+ *
+ *
Boundary conditions (3 tests)
+ *
Basic splitting (3 tests)
+ *
Dollar-quoting (8 tests)
+ *
E-string (3 tests)
+ *
Comment handling (4 tests)
+ *
String/Identifier handling (4 tests)
+ *
Complex scenarios (5 tests)
+ *
+ *
+ */
+public class PostgreSqlSplitterTest {
+
+ // ==================== 边界条件测试 (3 tests) ====================
+
+ @Test
+ public void split_Blank_Empty() {
+ String sql = " ";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertTrue(stmts.isEmpty());
+ }
+
+ @Test
+ public void split_Null_Empty() {
+ String sql = null;
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertTrue(stmts.isEmpty());
+ }
+
+ @Test
+ public void split_EmptyString_Empty() {
+ String sql = "";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertTrue(stmts.isEmpty());
+ }
+
+ // ==================== 基本切分测试 (3 tests) ====================
+
+ @Test
+ public void split_SingleStatement() {
+ String sql = "SELECT 1;";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(1, stmts.size());
+ Assert.assertEquals("SELECT 1;", stmts.get(0));
+ }
+
+ @Test
+ public void split_MultipleStatements() {
+ String sql = "SELECT 1;\nSELECT 2;";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(2, stmts.size());
+ Assert.assertEquals("SELECT 1;", stmts.get(0));
+ Assert.assertEquals("\nSELECT 2;", stmts.get(1));
+ }
+
+ @Test
+ public void split_NoTrailingDelimiter() {
+ String sql = "SELECT 1";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(1, stmts.size());
+ Assert.assertEquals("SELECT 1", stmts.get(0));
+ }
+
+ // ==================== Dollar-quoting 测试 (8 tests) ====================
+
+ @Test
+ public void split_DollarQuote_NoSplitInside() {
+ // Simple $$...$$ without tag
+ String sql = "SELECT $$hello;world$$;";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(1, stmts.size());
+ Assert.assertEquals("SELECT $$hello;world$$;", stmts.get(0));
+ }
+
+ @Test
+ public void split_DollarQuoteWithTag_NoSplitInside() {
+ // $tag$...$tag$ with custom tag
+ String sql = "SELECT $body$hello;world$body$;";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(1, stmts.size());
+ Assert.assertEquals("SELECT $body$hello;world$body$;", stmts.get(0));
+ }
+
+ @Test
+ public void split_DollarQuoteInFunction_NoSplitInside() {
+ // Realistic PG function with multiple semicolons inside $$
+ String sql = "CREATE FUNCTION test() RETURNS void AS $$\n" +
+ "BEGIN\n" +
+ " SELECT 1;\n" +
+ " SELECT 2;\n" +
+ "END;\n" +
+ "$$ LANGUAGE plpgsql;";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(1, stmts.size());
+ Assert.assertTrue(stmts.get(0).contains("$$"));
+ }
+
+ @Test
+ public void split_DollarQuoteWithUnderscoreTag() {
+ // Tag with underscore
+ String sql = "SELECT $my_tag$content;here$my_tag$;";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(1, stmts.size());
+ Assert.assertEquals("SELECT $my_tag$content;here$my_tag$;", stmts.get(0));
+ }
+
+ @Test
+ public void split_DollarQuoteWithNumericInTag() {
+ // Tag with numbers (not at the beginning)
+ String sql = "SELECT $tag123$content;here$tag123$;";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(1, stmts.size());
+ Assert.assertEquals("SELECT $tag123$content;here$tag123$;", stmts.get(0));
+ }
+
+ @Test
+ public void split_DollarQuoteEmptyContent() {
+ // Empty content between $$
+ String sql = "SELECT $$$$;";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(1, stmts.size());
+ Assert.assertEquals("SELECT $$$$;", stmts.get(0));
+ }
+
+ @Test
+ public void split_DollarQuoteNewlines() {
+ // Dollar-quoted string with newlines
+ String sql = "SELECT $$line1\nline2\n;line3$$;";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(1, stmts.size());
+ Assert.assertTrue(stmts.get(0).contains("line1"));
+ Assert.assertTrue(stmts.get(0).contains("line3"));
+ }
+
+ @Test
+ public void split_MultipleDollarQuotesInOneStatement() {
+ // Multiple dollar-quoted strings in one statement (different tags)
+ String sql = "SELECT $a$test1$a$, $b$test2;b$b$;";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(1, stmts.size());
+ Assert.assertTrue(stmts.get(0).contains("$a$"));
+ Assert.assertTrue(stmts.get(0).contains("$b$"));
+ }
+
+ // ==================== E-string 测试 (3 tests) ====================
+
+ @Test
+ public void split_EString_NoSplitInside() {
+ // E-string with semicolon
+ String sql = "SELECT E'hello;world';";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(1, stmts.size());
+ Assert.assertEquals("SELECT E'hello;world';", stmts.get(0));
+ }
+
+ @Test
+ public void split_EStringWithBackslashEscape() {
+ // E-string with backslash escapes
+ String sql = "SELECT E'line1\\nline2\\ttab';";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(1, stmts.size());
+ Assert.assertEquals("SELECT E'line1\\nline2\\ttab';", stmts.get(0));
+ }
+
+ @Test
+ public void split_EStringWithBackslashQuote() {
+ // E-string with escaped quote via backslash
+ String sql = "SELECT E'I\\'m here';";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(1, stmts.size());
+ Assert.assertTrue(stmts.get(0).contains("E'I\\'m here'"));
+ }
+
+ // ==================== 注释处理测试 (4 tests) ====================
+
+ @Test
+ public void split_LineComment_SemicolonIgnored() {
+ // Semicolon in line comment should not split
+ // The semicolon BEFORE the comment triggers split, but semicolon IN comment does not
+ String sql = "SELECT 1 -- comment; here\n;SELECT 2;";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(2, stmts.size());
+ Assert.assertEquals("SELECT 1 -- comment; here\n;", stmts.get(0));
+ Assert.assertEquals("SELECT 2;", stmts.get(1));
+ }
+
+ @Test
+ public void split_BlockComment_SemicolonIgnored() {
+ // Semicolon in block comment should not split
+ String sql = "SELECT 1 /* comment; here */;SELECT 2;";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(2, stmts.size());
+ Assert.assertTrue(stmts.get(0).contains("/* comment; here */"));
+ Assert.assertEquals("SELECT 2;", stmts.get(1));
+ }
+
+ @Test
+ public void split_NestedBlockComment_NoSplitInside() {
+ // PostgreSQL supports nested block comments
+ String sql = "SELECT 1 /* outer /* inner; nested */ back */;SELECT 2;";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(2, stmts.size());
+ Assert.assertTrue(stmts.get(0).contains("/* outer /* inner; nested */ back */"));
+ Assert.assertEquals("SELECT 2;", stmts.get(1));
+ }
+
+ @Test
+ public void split_DeeplyNestedBlockComment() {
+ // Deeply nested block comments
+ String sql = "SELECT 1 /* level1 /* level2 /* level3; */ */ */;SELECT 2;";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(2, stmts.size());
+ Assert.assertTrue(stmts.get(0).contains("level3;"));
+ Assert.assertEquals("SELECT 2;", stmts.get(1));
+ }
+
+ // ==================== 字符串/标识符测试 (4 tests) ====================
+
+ @Test
+ public void split_SingleQuoteString_NoSplitInside() {
+ // Semicolon in single-quoted string
+ String sql = "SELECT 'hello;world';";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(1, stmts.size());
+ Assert.assertEquals("SELECT 'hello;world';", stmts.get(0));
+ }
+
+ @Test
+ public void split_EscapedSingleQuote() {
+ // Doubled single quote escape
+ String sql = "SELECT 'it''s;ok';";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(1, stmts.size());
+ Assert.assertEquals("SELECT 'it''s;ok';", stmts.get(0));
+ }
+
+ @Test
+ public void split_DoubleQuoteIdentifier_NoSplitInside() {
+ // Semicolon in double-quoted identifier
+ String sql = "SELECT \"column;name\" FROM t;";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(1, stmts.size());
+ Assert.assertEquals("SELECT \"column;name\" FROM t;", stmts.get(0));
+ }
+
+ @Test
+ public void split_EscapedDoubleQuoteIdentifier() {
+ // Doubled double quote escape in identifier
+ String sql = "SELECT \"column\"\"name\" FROM t;";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(1, stmts.size());
+ Assert.assertEquals("SELECT \"column\"\"name\" FROM t;", stmts.get(0));
+ }
+
+ // ==================== 综合场景测试 (5 tests) ====================
+
+ @Test
+ public void split_ComplexFunction() {
+ // Complete PostgreSQL function with various constructs
+ String sql = "CREATE OR REPLACE FUNCTION test_func(p_id INTEGER)\n" +
+ "RETURNS INTEGER AS $$\n" +
+ "DECLARE\n" +
+ " v_result INTEGER;\n" +
+ "BEGIN\n" +
+ " SELECT col INTO v_result FROM table WHERE id = p_id;\n" +
+ " IF v_result > 0 THEN\n" +
+ " RETURN v_result;\n" +
+ " END IF;\n" +
+ " RETURN 0;\n" +
+ "END;\n" +
+ "$$ LANGUAGE plpgsql;\n" +
+ "SELECT test_func(1);";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(2, stmts.size());
+ Assert.assertTrue(stmts.get(0).contains("CREATE OR REPLACE FUNCTION"));
+ Assert.assertTrue(stmts.get(0).contains("$$"));
+ Assert.assertTrue(stmts.get(1).contains("test_func(1)"));
+ }
+
+ @Test
+ public void split_MixedConstructs() {
+ // Mix of comments, strings, identifiers, dollar quotes
+ String sql = "SELECT \"id\", 'value;1';\n" +
+ "/* block; comment */\n" +
+ "SELECT $$dollar;$$, E'e\\';string';";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(2, stmts.size());
+ }
+
+ @Test
+ public void split_OffsetCorrect() {
+ // Verify offset tracking
+ String sql = "SELECT 1;\nSELECT 2;";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql);
+
+ Assert.assertEquals(2, stmts.size());
+ Assert.assertEquals(0, stmts.get(0).getOffset());
+ Assert.assertEquals("SELECT 1;", stmts.get(0).getStr());
+ Assert.assertEquals(9, stmts.get(1).getOffset());
+ Assert.assertEquals("\nSELECT 2;", stmts.get(1).getStr());
+ }
+
+ @Test
+ public void split_ProcedureWithNamedDollarTag() {
+ // PostgreSQL 11+ procedure with named dollar tag
+ String sql = "CREATE OR REPLACE PROCEDURE my_proc()\n" +
+ "LANGUAGE plpgsql\n" +
+ "AS $procedure$\n" +
+ "BEGIN\n" +
+ " INSERT INTO log VALUES ('test');\n" +
+ " COMMIT;\n" +
+ "END;\n" +
+ "$procedure$;\n" +
+ "CALL my_proc();";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(2, stmts.size());
+ Assert.assertTrue(stmts.get(0).contains("CREATE OR REPLACE PROCEDURE"));
+ Assert.assertTrue(stmts.get(0).contains("$procedure$"));
+ Assert.assertTrue(stmts.get(1).contains("CALL my_proc()"));
+ }
+
+ @Test
+ public void split_MultipleSimilarTags() {
+ // Ensure different tags don't interfere
+ String sql = "SELECT $a$content$a$, $a$more$a$;\nSELECT $b$other$b$;";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(2, stmts.size());
+ }
+
+ // ==================== Iterator 测试 ====================
+
+ @Test
+ public void iterator_Basic() {
+ String sql = "SELECT 1;\nSELECT 2;";
+ SqlStatementIterator iterator = PostgreSqlSplitter.iterator(
+ new ByteArrayInputStream(sql.getBytes(StandardCharsets.UTF_8)),
+ StandardCharsets.UTF_8, ";");
+
+ List stmts = new ArrayList<>();
+ while (iterator.hasNext()) {
+ stmts.add(iterator.next().getStr());
+ }
+
+ Assert.assertEquals(2, stmts.size());
+ Assert.assertEquals("SELECT 1;", stmts.get(0));
+ Assert.assertEquals("\nSELECT 2;", stmts.get(1));
+ }
+
+ @Test
+ public void iterator_IteratedBytes() {
+ String sql = "SELECT 1;\nSELECT 2;";
+ SqlStatementIterator iterator = PostgreSqlSplitter.iterator(
+ new ByteArrayInputStream(sql.getBytes(StandardCharsets.UTF_8)),
+ StandardCharsets.UTF_8, ";");
+
+ long bytes = 0;
+ while (iterator.hasNext()) {
+ iterator.next();
+ bytes = iterator.iteratedBytes();
+ }
+
+ Assert.assertEquals(sql.length(), bytes);
+ }
+
+ // ==================== 额外边界测试 ====================
+
+ @Test
+ public void split_EStringLowercase() {
+ // Lowercase e prefix for E-string
+ String sql = "SELECT e'hello;world';";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(1, stmts.size());
+ Assert.assertEquals("SELECT e'hello;world';", stmts.get(0));
+ }
+
+ @Test
+ public void split_StandaloneDollarNotTreatedAsQuote() {
+ // Standalone $ not at word boundary should not start dollar-quoting
+ // (invalid dollar tag - can't start with digit)
+ String sql = "SELECT $1, $2 FROM table;";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(1, stmts.size());
+ Assert.assertTrue(stmts.get(0).contains("$1"));
+ Assert.assertTrue(stmts.get(0).contains("$2"));
+ }
+
+ @Test
+ public void split_EStringWithDoubleQuoteEscape() {
+ // E-string also supports '' escape
+ String sql = "SELECT E'it''s ok';";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(1, stmts.size());
+ Assert.assertEquals("SELECT E'it''s ok';", stmts.get(0));
+ }
+
+ @Test
+ public void split_DollarQuoteContainsQuotes() {
+ // Dollar-quoted string containing quotes
+ String sql = "SELECT $$it's \"quoted\"$$;";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter();
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(1, stmts.size());
+ Assert.assertTrue(stmts.get(0).contains("it's"));
+ Assert.assertTrue(stmts.get(0).contains("\"quoted\""));
+ }
+
+ @Test
+ public void split_CustomDelimiter() {
+ // Using custom delimiter (not dollar sign to avoid conflict with dollar-quoting)
+ String sql = "SELECT 1@\nSELECT 2@";
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter("@");
+
+ List stmts = splitter.split(sql).stream()
+ .map(OffsetString::getStr)
+ .collect(Collectors.toList());
+
+ Assert.assertEquals(2, stmts.size());
+ Assert.assertEquals("SELECT 1@", stmts.get(0));
+ Assert.assertEquals("\nSELECT 2@", stmts.get(1));
+ }
+}
From 2b0f0dbd90474fc015ea289d8e07bf5539ef1fd1 Mon Sep 17 00:00:00 2001
From: sjjian <921465802@qq.com>
Date: Wed, 11 Mar 2026 11:35:50 +0000
Subject: [PATCH 05/25] feat(postgres): integrate PostgreSqlSplitter and
PostgresSqlBuilder into service layer
Integrate PostgreSQL SQL processing components into ODC service layer:
- Add PostgresSqlBuilder for PostgreSQL identifier/value quoting
- Modify SqlUtils.split() to use PostgreSqlSplitter for PostgreSQL dialect
- Modify SqlUtils.iterator() to support PostgreSQL streaming SQL parsing
- Modify ConnectConsoleService.queryTableOrViewData() to use PostgresSqlBuilder
- Modify ConnectConsoleService.streamExecute() to enable PostgreSQL SQL splitting
PostgreSQL requires special SQL splitting due to:
- Dollar-quoting ($$...$$, $tag$...$tag$) in function bodies
- E-string (E'...') with backslash escapes
- Nested block comments support
Covers: AC-003.1, AC-003.5, AC-003.7
---
.../dbbrowser/util/PostgresSqlBuilder.java | 120 ++++++
.../util/PostgresSqlBuilderTest.java | 353 ++++++++++++++++++
.../odc/service/common/util/SqlUtils.java | 12 +
.../session/ConnectConsoleService.java | 16 +-
4 files changed, 496 insertions(+), 5 deletions(-)
create mode 100644 libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/util/PostgresSqlBuilder.java
create mode 100644 libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/util/PostgresSqlBuilderTest.java
diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/util/PostgresSqlBuilder.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/util/PostgresSqlBuilder.java
new file mode 100644
index 0000000000..2eec2a1690
--- /dev/null
+++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/util/PostgresSqlBuilder.java
@@ -0,0 +1,120 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.tools.dbbrowser.util;
+
+/**
+ * PostgreSQL SQL builder.
+ *
+ *
+ * PostgreSQL uses double quotes for identifiers and single quotes for string values. The escaping
+ * rules are:
+ *
+ *
Identifiers: use double quotes, internal double quotes are escaped by doubling: "column"
+ *
Values: use single quotes, internal single quotes are escaped by doubling: 'value'
+ *
+ *
+ *
+ *
+ * Note: PostgreSQL identifier quoting is similar to Oracle (both use double quotes), but the LIKE
+ * clause escaping behavior differs. Oracle requires explicit ESCAPE clause, while PostgreSQL treats
+ * backslash as a literal character by default in LIKE patterns (unless standard_conforming_strings
+ * is off).
+ *
+ * PostgreSQL uses double quotes for identifiers. Internal double quotes are escaped by doubling
+ * them.
+ *
+ *
+ * @param identifier the identifier to quote
+ * @return this SqlBuilder
+ */
+ @Override
+ public SqlBuilder identifier(String identifier) {
+ if (StringUtils.isBlank(identifier)) {
+ return this;
+ }
+ // PostgreSQL uses double quotes for identifiers, same as Oracle
+ return append(StringUtils.quoteOracleIdentifier(identifier));
+ }
+
+ /**
+ * Append value with PostgreSQL quoting rules.
+ *
+ *
+ * PostgreSQL uses single quotes for string values. Internal single quotes are escaped by doubling
+ * them.
+ *
+ *
+ * @param value the value to quote
+ * @return this SqlBuilder
+ */
+ @Override
+ public SqlBuilder value(String value) {
+ if (value == null) {
+ return append("NULL");
+ }
+ // PostgreSQL uses single quotes for values, same as Oracle
+ return append(StringUtils.quoteOracleValue(value));
+ }
+
+ /**
+ * Append default value.
+ *
+ *
+ * Default values in PostgreSQL are typically function calls or literals, so they are appended
+ * as-is.
+ *
+ *
+ * @param value the default value
+ * @return this SqlBuilder
+ */
+ @Override
+ public SqlBuilder defaultValue(String value) {
+ return append(value);
+ }
+
+ /**
+ * Append LIKE clause.
+ *
+ *
+ * PostgreSQL LIKE clause uses backslash for escaping by default (when standard_conforming_strings
+ * is off) or can use the ESCAPE clause explicitly. For simplicity, we use the base implementation
+ * without explicit ESCAPE clause, which differs from Oracle that always appends ESCAPE '\'.
+ *
+ *
+ * @param fieldKey the field name
+ * @param fieldLikeValue the like pattern value
+ * @return this SqlBuilder
+ */
+ @Override
+ public SqlBuilder like(String fieldKey, String fieldLikeValue) {
+ // Use base implementation without explicit ESCAPE clause
+ // PostgreSQL's LIKE behavior depends on standard_conforming_strings setting
+ return super.like(fieldKey, fieldLikeValue);
+ }
+}
diff --git a/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/util/PostgresSqlBuilderTest.java b/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/util/PostgresSqlBuilderTest.java
new file mode 100644
index 0000000000..1c400d502c
--- /dev/null
+++ b/libs/db-browser/src/test/java/com/oceanbase/tools/dbbrowser/util/PostgresSqlBuilderTest.java
@@ -0,0 +1,353 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.tools.dbbrowser.util;
+
+import java.util.Arrays;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link PostgresSqlBuilder}.
+ *
+ *
+ * Test coverage:
+ *
+ *
Identifier quoting with double quotes
+ *
Value quoting with single quotes
+ *
Escape handling for embedded quotes
+ *
Schema prefix handling
+ *
LIKE clause handling
+ *
NULL value handling
+ *
+ *
+ */
+public class PostgresSqlBuilderTest {
+
+ private PostgresSqlBuilder builder;
+
+ @Before
+ public void setUp() {
+ builder = new PostgresSqlBuilder();
+ }
+
+ // ========== Identifier Tests ==========
+
+ /**
+ * Test basic identifier quoting. PostgreSQL uses double quotes for identifiers.
+ */
+ @Test
+ public void testIdentifier_Basic() {
+ String result = builder.identifier("table_name").toString();
+ Assert.assertEquals("\"table_name\"", result);
+ }
+
+ /**
+ * Test identifier with embedded double quote. Double quotes are escaped by doubling.
+ */
+ @Test
+ public void testIdentifier_EscapeDoubleQuote() {
+ String result = builder.identifier("col\"umn").toString();
+ Assert.assertEquals("\"col\"\"umn\"", result);
+ }
+
+ /**
+ * Test blank identifier returns empty.
+ */
+ @Test
+ public void testIdentifier_Blank() {
+ String result = builder.identifier("").toString();
+ Assert.assertEquals("", result);
+ }
+
+ /**
+ * Test null identifier returns empty.
+ */
+ @Test
+ public void testIdentifier_Null() {
+ String result = builder.identifier(null).toString();
+ Assert.assertEquals("", result);
+ }
+
+ /**
+ * Test multiple identifiers in sequence.
+ */
+ @Test
+ public void testIdentifier_Multiple() {
+ String result = builder.identifier("schema").append(".").identifier("table").toString();
+ Assert.assertEquals("\"schema\".\"table\"", result);
+ }
+
+ // ========== Value Tests ==========
+
+ /**
+ * Test basic value quoting. PostgreSQL uses single quotes for values.
+ */
+ @Test
+ public void testValue_Basic() {
+ String result = builder.value("hello").toString();
+ Assert.assertEquals("'hello'", result);
+ }
+
+ /**
+ * Test value with embedded single quote. Single quotes are escaped by doubling.
+ */
+ @Test
+ public void testValue_EscapeSingleQuote() {
+ String result = builder.value("it's").toString();
+ Assert.assertEquals("'it''s'", result);
+ }
+
+ /**
+ * Test value with multiple embedded single quotes.
+ */
+ @Test
+ public void testValue_MultipleSingleQuotes() {
+ String result = builder.value("it's a test, isn't it?").toString();
+ Assert.assertEquals("'it''s a test, isn''t it?'", result);
+ }
+
+ /**
+ * Test null value returns NULL (SQL keyword).
+ */
+ @Test
+ public void testValue_Null() {
+ String result = builder.value(null).toString();
+ Assert.assertEquals("NULL", result);
+ }
+
+ /**
+ * Test empty value.
+ */
+ @Test
+ public void testValue_Empty() {
+ String result = builder.value("").toString();
+ Assert.assertEquals("''", result);
+ }
+
+ // ========== DefaultValue Tests ==========
+
+ /**
+ * Test default value is appended as-is.
+ */
+ @Test
+ public void testDefaultValue_Function() {
+ String result = builder.defaultValue("now()").toString();
+ Assert.assertEquals("now()", result);
+ }
+
+ /**
+ * Test default value with literal.
+ */
+ @Test
+ public void testDefaultValue_Literal() {
+ String result = builder.defaultValue("'default'").toString();
+ Assert.assertEquals("'default'", result);
+ }
+
+ // ========== SchemaPrefix Tests ==========
+
+ /**
+ * Test schema prefix adds identifier with dot.
+ */
+ @Test
+ public void testSchemaPrefixIfNotBlank_Basic() {
+ String result = builder.schemaPrefixIfNotBlank("myschema").identifier("mytable").toString();
+ Assert.assertEquals("\"myschema\".\"mytable\"", result);
+ }
+
+ /**
+ * Test blank schema prefix is skipped.
+ */
+ @Test
+ public void testSchemaPrefixIfNotBlank_Blank() {
+ String result = builder.schemaPrefixIfNotBlank("").identifier("mytable").toString();
+ Assert.assertEquals("\"mytable\"", result);
+ }
+
+ /**
+ * Test null schema prefix is skipped.
+ */
+ @Test
+ public void testSchemaPrefixIfNotBlank_Null() {
+ String result = builder.schemaPrefixIfNotBlank(null).identifier("mytable").toString();
+ Assert.assertEquals("\"mytable\"", result);
+ }
+
+ // ========== identifier(String, String) Tests ==========
+
+ /**
+ * Test two-argument identifier method.
+ */
+ @Test
+ public void testIdentifier_TwoArgs() {
+ String result = builder.identifier("schema", "table").toString();
+ Assert.assertEquals("\"schema\".\"table\"", result);
+ }
+
+ /**
+ * Test two-argument identifier with null schema.
+ */
+ @Test
+ public void testIdentifier_TwoArgs_NullSchema() {
+ String result = builder.identifier(null, "table").toString();
+ Assert.assertEquals("\"table\"", result);
+ }
+
+ // ========== LIKE Tests ==========
+
+ /**
+ * Test LIKE clause without explicit ESCAPE. PostgreSQL's LIKE behavior differs from Oracle which
+ * adds "ESCAPE '\'".
+ */
+ @Test
+ public void testLike_Basic() {
+ String result = builder.like("name", "test").toString();
+ Assert.assertEquals("name LIKE '%test%'", result);
+ }
+
+ /**
+ * Test LIKE clause with special characters.
+ */
+ @Test
+ public void testLike_SpecialChars() {
+ String result = builder.like("name", "%test").toString();
+ // % should be escaped in the like pattern
+ Assert.assertTrue(result.contains("\\%"));
+ }
+
+ // ========== List Tests ==========
+
+ /**
+ * Test identifiers list.
+ */
+ @Test
+ public void testIdentifiers_List() {
+ String result = builder.identifiers(Arrays.asList("col1", "col2", "col3")).toString();
+ Assert.assertEquals("\"col1\",\"col2\",\"col3\"", result);
+ }
+
+ /**
+ * Test values list.
+ */
+ @Test
+ public void testValues_List() {
+ String result = builder.values(Arrays.asList("val1", "val2")).toString();
+ Assert.assertEquals("'val1','val2'", result);
+ }
+
+ // ========== Complex SQL Construction Tests ==========
+
+ /**
+ * Test building a simple SELECT statement.
+ */
+ @Test
+ public void testBuildSelectStatement() {
+ String result = builder.append("SELECT ")
+ .identifiers(Arrays.asList("id", "name"))
+ .append(" FROM ")
+ .identifier("public", "users")
+ .append(" WHERE ")
+ .identifier("status")
+ .append(" = ")
+ .value("active")
+ .toString();
+ Assert.assertEquals("SELECT \"id\",\"name\" FROM \"public\".\"users\" WHERE \"status\" = 'active'", result);
+ }
+
+ /**
+ * Test building an INSERT statement.
+ */
+ @Test
+ public void testBuildInsertStatement() {
+ String result = builder.append("INSERT INTO ")
+ .identifier("public", "users")
+ .append(" (")
+ .identifiers(Arrays.asList("id", "name"))
+ .append(") VALUES (")
+ .values(Arrays.asList("1", "John's Data"))
+ .append(")")
+ .toString();
+ Assert.assertEquals(
+ "INSERT INTO \"public\".\"users\" (\"id\",\"name\") VALUES ('1','John''s Data')",
+ result);
+ }
+
+ /**
+ * Test building a CREATE TABLE statement with reserved keywords.
+ */
+ @Test
+ public void testBuildCreateTableWithReservedKeywords() {
+ String result = builder.append("CREATE TABLE ")
+ .identifier("public", "order")
+ .append(" (")
+ .identifier("id").append(" SERIAL PRIMARY KEY, ")
+ .identifier("user").append(" VARCHAR(100), ")
+ .identifier("table").append(" VARCHAR(100)")
+ .append(")")
+ .toString();
+ Assert.assertEquals(
+ "CREATE TABLE \"public\".\"order\" (\"id\" SERIAL PRIMARY KEY, \"user\" VARCHAR(100), \"table\" VARCHAR(100))",
+ result);
+ }
+
+ // ========== Comparison with Oracle Behavior ==========
+
+ /**
+ * Verify that PostgreSQL identifier quoting is same as Oracle (both use double quotes).
+ */
+ @Test
+ public void testIdentifier_SameAsOracle() {
+ PostgresSqlBuilder pgBuilder = new PostgresSqlBuilder();
+ OracleSqlBuilder oracleBuilder = new OracleSqlBuilder();
+
+ String pgResult = pgBuilder.identifier("table_name").toString();
+ String oracleResult = oracleBuilder.identifier("table_name").toString();
+
+ Assert.assertEquals("PostgreSQL and Oracle should have same identifier quoting", oracleResult, pgResult);
+ }
+
+ /**
+ * Verify that PostgreSQL value quoting is same as Oracle (both use single quotes).
+ */
+ @Test
+ public void testValue_SameAsOracle() {
+ PostgresSqlBuilder pgBuilder = new PostgresSqlBuilder();
+ OracleSqlBuilder oracleBuilder = new OracleSqlBuilder();
+
+ String pgResult = pgBuilder.value("test's value").toString();
+ String oracleResult = oracleBuilder.value("test's value").toString();
+
+ Assert.assertEquals("PostgreSQL and Oracle should have same value quoting", oracleResult, pgResult);
+ }
+
+ /**
+ * Verify LIKE clause differs from Oracle. Oracle appends "ESCAPE '\'" after LIKE clause.
+ */
+ @Test
+ public void testLike_DifferentFromOracle() {
+ PostgresSqlBuilder pgBuilder = new PostgresSqlBuilder();
+ OracleSqlBuilder oracleBuilder = new OracleSqlBuilder();
+
+ String pgResult = pgBuilder.like("name", "test").toString();
+ String oracleResult = oracleBuilder.like("name", "test").toString();
+
+ // Oracle adds ESCAPE '\' at the end
+ Assert.assertFalse("PostgreSQL should not have ESCAPE clause", pgResult.contains("ESCAPE"));
+ Assert.assertTrue("Oracle should have ESCAPE clause", oracleResult.contains("ESCAPE"));
+ }
+}
diff --git a/server/odc-service/src/main/java/com/oceanbase/odc/service/common/util/SqlUtils.java b/server/odc-service/src/main/java/com/oceanbase/odc/service/common/util/SqlUtils.java
index 195e6402c5..bc2e35be30 100644
--- a/server/odc-service/src/main/java/com/oceanbase/odc/service/common/util/SqlUtils.java
+++ b/server/odc-service/src/main/java/com/oceanbase/odc/service/common/util/SqlUtils.java
@@ -29,6 +29,7 @@
import com.oceanbase.odc.core.shared.PreConditions;
import com.oceanbase.odc.core.shared.constant.DialectType;
import com.oceanbase.odc.core.sql.split.OffsetString;
+import com.oceanbase.odc.core.sql.split.PostgreSqlSplitter;
import com.oceanbase.odc.core.sql.split.SqlCommentProcessor;
import com.oceanbase.odc.core.sql.split.SqlServerSqlSplitter;
import com.oceanbase.odc.core.sql.split.SqlSplitter;
@@ -128,6 +129,14 @@ private static List split(DialectType dialectType, SqlCommentProce
SqlServerSqlSplitter splitter = new SqlServerSqlSplitter(processor.getDelimiter());
return splitter.split(sql);
}
+ if (dialectType.isPostgreSql()) {
+ // PostgreSQL needs special splitting for:
+ // - dollar-quoting: $$...$$ and $tag$...$tag$
+ // - E-string: E'...' with backslash escapes
+ // - nested block comments
+ PostgreSqlSplitter splitter = new PostgreSqlSplitter(processor.getDelimiter());
+ return splitter.split(sql);
+ }
if (dialectType.isOracle()
&& (";".equals(processor.getDelimiter()) || "/".equals(processor.getDelimiter()))) {
SqlSplitter sqlSplitter = new SqlSplitter(PlSqlLexer.class, processor.getDelimiter(), false);
@@ -176,6 +185,9 @@ private static SqlStatementIterator iterator(InputStream input, Charset charset,
if (Objects.nonNull(dialectType) && dialectType.isSqlServer()) {
return SqlServerSqlSplitter.iterator(input, charset, processor.getDelimiter());
}
+ if (Objects.nonNull(dialectType) && dialectType.isPostgreSql()) {
+ return PostgreSqlSplitter.iterator(input, charset, processor.getDelimiter());
+ }
if (Objects.nonNull(dialectType) && dialectType.isOracle()
&& (";".equals(processor.getDelimiter()) || "/".equals(processor.getDelimiter()))) {
return SqlSplitter.iterator(input, charset, processor.getDelimiter(), false);
diff --git a/server/odc-service/src/main/java/com/oceanbase/odc/service/session/ConnectConsoleService.java b/server/odc-service/src/main/java/com/oceanbase/odc/service/session/ConnectConsoleService.java
index 65537d3dbf..3ede861c5a 100644
--- a/server/odc-service/src/main/java/com/oceanbase/odc/service/session/ConnectConsoleService.java
+++ b/server/odc-service/src/main/java/com/oceanbase/odc/service/session/ConnectConsoleService.java
@@ -109,6 +109,7 @@
import com.oceanbase.tools.dbbrowser.schema.DBSchemaAccessor;
import com.oceanbase.tools.dbbrowser.util.MySQLSqlBuilder;
import com.oceanbase.tools.dbbrowser.util.OracleSqlBuilder;
+import com.oceanbase.tools.dbbrowser.util.PostgresSqlBuilder;
import com.oceanbase.tools.dbbrowser.util.SqlBuilder;
import com.oceanbase.tools.dbbrowser.util.SqlServerSqlBuilder;
@@ -167,6 +168,8 @@ public SqlExecuteResult queryTableOrViewData(@NotNull String sessionId,
sqlBuilder = new MySQLSqlBuilder();
} else if (dialectType.isSqlServer()) {
sqlBuilder = new SqlServerSqlBuilder();
+ } else if (dialectType.isPostgreSql()) {
+ sqlBuilder = new PostgresSqlBuilder();
} else {
throw new IllegalArgumentException("Unsupported dialect type, " + dialectType);
}
@@ -261,11 +264,14 @@ public SqlAsyncExecuteResp streamExecute(@NotNull String sessionId,
StringUtils.length(request.getSql()), maxSqlLength);
}
- // SQL Server 需要应该通过按行的 GO 进行分割 临时代码放在公共层,后续应当移动到SQLServer适配层
- List sqls = (request.ifSplitSqls() || connectionSession.getDialectType().isSqlServer())
- ? SqlUtils.splitWithOffset(connectionSession, request.getSql(),
- sessionProperties.isOracleRemoveCommentPrefix())
- : Collections.singletonList(new OffsetString(0, request.getSql()));
+ // SQL Server needs batch-aware splitting by line-based GO
+ // PostgreSQL needs special splitting for dollar-quoting, E-string, etc.
+ List sqls = (request.ifSplitSqls()
+ || connectionSession.getDialectType().isSqlServer()
+ || connectionSession.getDialectType().isPostgreSql())
+ ? SqlUtils.splitWithOffset(connectionSession, request.getSql(),
+ sessionProperties.isOracleRemoveCommentPrefix())
+ : Collections.singletonList(new OffsetString(0, request.getSql()));
if (sqls.size() == 0) {
/**
* if a sql only contains delimiter setting(eg. delimiter $$), code will do this
From 02f1156dbd914f193285e2116e1e03f97cab1318 Mon Sep 17 00:00:00 2001
From: sjjian <921465802@qq.com>
Date: Wed, 11 Mar 2026 11:49:34 +0000
Subject: [PATCH 06/25] feat(postgres): implement PostgreSQLFeatures and
register in AllFeatures
Implement PostgreSQLFeatures class to define PostgreSQL-specific
feature support in ODC:
- Create PostgreSQLFeatures extending DefaultFeatures
- Override 8 feature methods for PostgreSQL:
- supportsShowTrace(): false (PG has no show trace)
- supportsViewObject(): true
- supportsOBTenant(): false (PG has no tenant concept)
- supportsShowTenant(): false
- supportsProcedure(): true (PG 11+ supports procedures)
- supportsSchemaPrefixInSql(): true
- supportsExplain(): true
- supportsAutoIncrement(): false (PG uses SERIAL/IDENTITY)
- Register POSTGRESQL constant in AllFeatures.getByConnectType()
- Add unit tests with 11 test cases covering all methods
Covers requirements: AC-005.8, NFR-001.2
---
.../odc/service/feature/AllFeatures.java | 3 +
.../service/feature/PostgreSQLFeatures.java | 88 ++++++++++++
.../feature/PostgreSQLFeaturesTest.java | 133 ++++++++++++++++++
3 files changed, 224 insertions(+)
create mode 100644 server/odc-service/src/main/java/com/oceanbase/odc/service/feature/PostgreSQLFeatures.java
create mode 100644 server/odc-service/src/test/java/com/oceanbase/odc/service/feature/PostgreSQLFeaturesTest.java
diff --git a/server/odc-service/src/main/java/com/oceanbase/odc/service/feature/AllFeatures.java b/server/odc-service/src/main/java/com/oceanbase/odc/service/feature/AllFeatures.java
index 88b0743214..c981aeda30 100644
--- a/server/odc-service/src/main/java/com/oceanbase/odc/service/feature/AllFeatures.java
+++ b/server/odc-service/src/main/java/com/oceanbase/odc/service/feature/AllFeatures.java
@@ -23,6 +23,7 @@ public class AllFeatures {
private static final Features OB_MYSQL = new OBMySQLFeatures();
private static final Features ODP_SHARDING = new ODPShardingFeatures();
private static final Features MYSQL = new MySQLFeatures();
+ private static final Features POSTGRESQL = new PostgreSQLFeatures();
public static Features getByConnectType(ConnectType connectType) {
PreConditions.notNull(connectType, "connectType");
@@ -34,6 +35,8 @@ public static Features getByConnectType(ConnectType connectType) {
return ODP_SHARDING;
case MYSQL:
return MYSQL;
+ case POSTGRESQL:
+ return POSTGRESQL;
default:
return DEFAULT;
}
diff --git a/server/odc-service/src/main/java/com/oceanbase/odc/service/feature/PostgreSQLFeatures.java b/server/odc-service/src/main/java/com/oceanbase/odc/service/feature/PostgreSQLFeatures.java
new file mode 100644
index 0000000000..eedf31b140
--- /dev/null
+++ b/server/odc-service/src/main/java/com/oceanbase/odc/service/feature/PostgreSQLFeatures.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.service.feature;
+
+/**
+ * PostgreSQL 数据库功能特性配置
+ *
+ * 定义 PostgreSQL 在 ODC 中支持的功能特性
+ */
+public class PostgreSQLFeatures extends DefaultFeatures {
+
+ /**
+ * PostgreSQL 不支持 OceanBase 的 show trace 命令
+ */
+ @Override
+ public boolean supportsShowTrace() {
+ return false;
+ }
+
+ /**
+ * PostgreSQL 支持视图对象
+ */
+ @Override
+ public boolean supportsViewObject() {
+ return true;
+ }
+
+ /**
+ * PostgreSQL 没有 OceanBase 的租户概念
+ */
+ @Override
+ public boolean supportsOBTenant() {
+ return false;
+ }
+
+ /**
+ * PostgreSQL 没有 show tenant 命令(因为它没有租户概念)
+ */
+ @Override
+ public boolean supportsShowTenant() {
+ return false;
+ }
+
+ /**
+ * PostgreSQL 11+ 支持存储过程(CREATE PROCEDURE)
+ */
+ @Override
+ public boolean supportsProcedure() {
+ return true;
+ }
+
+ /**
+ * PostgreSQL 支持 SQL 中的 schema 前缀(schema.table 格式)
+ */
+ @Override
+ public boolean supportsSchemaPrefixInSql() {
+ return true;
+ }
+
+ /**
+ * PostgreSQL 支持 EXPLAIN 命令查看执行计划
+ */
+ @Override
+ public boolean supportsExplain() {
+ return true;
+ }
+
+ /**
+ * PostgreSQL 不使用 AUTO_INCREMENT,而是使用 SERIAL/BIGSERIAL 或 IDENTITY 列
+ */
+ @Override
+ public boolean supportsAutoIncrement() {
+ return false;
+ }
+}
diff --git a/server/odc-service/src/test/java/com/oceanbase/odc/service/feature/PostgreSQLFeaturesTest.java b/server/odc-service/src/test/java/com/oceanbase/odc/service/feature/PostgreSQLFeaturesTest.java
new file mode 100644
index 0000000000..0452225e2c
--- /dev/null
+++ b/server/odc-service/src/test/java/com/oceanbase/odc/service/feature/PostgreSQLFeaturesTest.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.service.feature;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import com.oceanbase.odc.core.shared.constant.ConnectType;
+
+/**
+ * Unit tests for PostgreSQLFeatures
+ */
+public class PostgreSQLFeaturesTest {
+
+ private static final Features FEATURES = new PostgreSQLFeatures();
+
+ /**
+ * Test case 1: PostgreSQL 不支持 show trace 设计文档 Section 3.5.1: supportsShowTrace() = false
+ */
+ @Test
+ public void testSupportsShowTrace_returnsFalse() {
+ Assert.assertFalse("PostgreSQL should not support show trace", FEATURES.supportsShowTrace());
+ }
+
+ /**
+ * Test case 2: PostgreSQL 支持视图对象 设计文档 Section 3.5.1: supportsViewObject() = true
+ */
+ @Test
+ public void testSupportsViewObject_returnsTrue() {
+ Assert.assertTrue("PostgreSQL should support view object", FEATURES.supportsViewObject());
+ }
+
+ /**
+ * Test case 3: PostgreSQL 没有 OceanBase 的租户概念 设计文档 Section 3.5.1: supportsOBTenant() = false
+ */
+ @Test
+ public void testSupportsOBTenant_returnsFalse() {
+ Assert.assertFalse("PostgreSQL should not support OB tenant", FEATURES.supportsOBTenant());
+ }
+
+ /**
+ * Test case 4: PostgreSQL 没有 show tenant 命令 设计文档 Section 3.5.1: supportsShowTenant() = false
+ */
+ @Test
+ public void testSupportsShowTenant_returnsFalse() {
+ Assert.assertFalse("PostgreSQL should not support show tenant", FEATURES.supportsShowTenant());
+ }
+
+ /**
+ * Test case 5: PostgreSQL 11+ 支持存储过程 设计文档 Section 3.5.1: supportsProcedure() = true
+ */
+ @Test
+ public void testSupportsProcedure_returnsTrue() {
+ Assert.assertTrue("PostgreSQL should support procedure", FEATURES.supportsProcedure());
+ }
+
+ /**
+ * Test case 6: PostgreSQL 支持 SQL 中的 schema 前缀 设计文档 Section 3.5.1: supportsSchemaPrefixInSql() =
+ * true
+ */
+ @Test
+ public void testSupportsSchemaPrefixInSql_returnsTrue() {
+ Assert.assertTrue("PostgreSQL should support schema prefix in SQL", FEATURES.supportsSchemaPrefixInSql());
+ }
+
+ /**
+ * Test case 7: PostgreSQL 支持 EXPLAIN 命令 设计文档 Section 3.5.1: supportsExplain() = true
+ */
+ @Test
+ public void testSupportsExplain_returnsTrue() {
+ Assert.assertTrue("PostgreSQL should support explain", FEATURES.supportsExplain());
+ }
+
+ /**
+ * Test case 8: PostgreSQL 不使用 AUTO_INCREMENT 设计文档 Section 3.5.1: supportsAutoIncrement() = false
+ * (PG 用 SERIAL/IDENTITY)
+ */
+ @Test
+ public void testSupportsAutoIncrement_returnsFalse() {
+ Assert.assertFalse("PostgreSQL should not support auto_increment (uses SERIAL/IDENTITY)",
+ FEATURES.supportsAutoIncrement());
+ }
+
+ /**
+ * Test case 9: 验证 AllFeatures.getByConnectType() 对 POSTGRESQL 类型返回正确的 Features
+ */
+ @Test
+ public void testAllFeatures_getByConnectType_postgresql() {
+ Features features = AllFeatures.getByConnectType(ConnectType.POSTGRESQL);
+ Assert.assertNotNull("Features should not be null for POSTGRESQL connect type", features);
+ Assert.assertTrue("Should return PostgreSQLFeatures instance",
+ features instanceof PostgreSQLFeatures);
+ }
+
+ /**
+ * Test case 10: 验证通过 AllFeatures 获取的 PostgreSQLFeatures 各方法返回正确值
+ */
+ @Test
+ public void testAllFeatures_postgresqlFeatures_allMethods() {
+ Features features = AllFeatures.getByConnectType(ConnectType.POSTGRESQL);
+
+ Assert.assertFalse("show trace should be false", features.supportsShowTrace());
+ Assert.assertTrue("view object should be true", features.supportsViewObject());
+ Assert.assertFalse("OB tenant should be false", features.supportsOBTenant());
+ Assert.assertFalse("show tenant should be false", features.supportsShowTenant());
+ Assert.assertTrue("procedure should be true", features.supportsProcedure());
+ Assert.assertTrue("schema prefix should be true", features.supportsSchemaPrefixInSql());
+ Assert.assertTrue("explain should be true", features.supportsExplain());
+ Assert.assertFalse("auto increment should be false", features.supportsAutoIncrement());
+ }
+
+ /**
+ * Test case 11: 验证 PostgreSQLFeatures 继承自 DefaultFeatures 确保 PostgreSQLFeatures 可以正确覆写父类方法
+ */
+ @Test
+ public void testPostgreSQLFeatures_extendsDefaultFeatures() {
+ Assert.assertTrue("PostgreSQLFeatures should extend DefaultFeatures",
+ DefaultFeatures.class.isAssignableFrom(PostgreSQLFeatures.class));
+ }
+}
From 85d4b636d47dde3855997bda701a77cc9cc02e6e Mon Sep 17 00:00:00 2001
From: sjjian <921465802@qq.com>
Date: Wed, 11 Mar 2026 12:14:57 +0000
Subject: [PATCH 07/25] feat(postgres): implement
PostgresDiagnoseExtensionPoint and PostgresTraceExtension
- Add PostgresDiagnoseExtensionPoint implementing SqlDiagnoseExtensionPoint
- getExplain() executes EXPLAIN and returns execution plan text
- Other methods (getPhysicalPlanBySqlId, etc.) throw UnsupportedOperationException
- Add PostgresTraceExtension implementing TraceExtensionPoint
- getExecuteDetail() returns empty SqlExecTime as placeholder
- PostgreSQL has no built-in trace mechanism like OceanBase
- Add unit tests for both extensions (34 test cases total)
- Add mockito-core test dependency to connect-plugin-postgres
Reference: SQLServer implementation pattern for consistency
Requirement: FR-011, AC-011.1, AC-011.3, NFR-003.2
---
.../plugins/connect-plugin-postgres/pom.xml | 5 +
.../PostgresDiagnoseExtensionPoint.java | 188 ++++++++
.../postgres/PostgresTraceExtension.java | 84 ++++
.../PostgresDiagnoseExtensionPointTest.java | 435 ++++++++++++++++++
.../PostgresSessionExtensionTest.java | 5 +-
.../postgres/PostgresTraceExtensionTest.java | 282 ++++++++++++
6 files changed, 995 insertions(+), 4 deletions(-)
create mode 100644 server/plugins/connect-plugin-postgres/src/main/java/com/oceanbase/odc/plugin/connect/postgres/PostgresDiagnoseExtensionPoint.java
create mode 100644 server/plugins/connect-plugin-postgres/src/main/java/com/oceanbase/odc/plugin/connect/postgres/PostgresTraceExtension.java
create mode 100644 server/plugins/connect-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/connect/postgres/PostgresDiagnoseExtensionPointTest.java
create mode 100644 server/plugins/connect-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/connect/postgres/PostgresTraceExtensionTest.java
diff --git a/server/plugins/connect-plugin-postgres/pom.xml b/server/plugins/connect-plugin-postgres/pom.xml
index bf8a45c008..8524b25a2f 100644
--- a/server/plugins/connect-plugin-postgres/pom.xml
+++ b/server/plugins/connect-plugin-postgres/pom.xml
@@ -56,6 +56,11 @@
com.oceanbaseodc-test
+
+ org.mockito
+ mockito-core
+ test
+
diff --git a/server/plugins/connect-plugin-postgres/src/main/java/com/oceanbase/odc/plugin/connect/postgres/PostgresDiagnoseExtensionPoint.java b/server/plugins/connect-plugin-postgres/src/main/java/com/oceanbase/odc/plugin/connect/postgres/PostgresDiagnoseExtensionPoint.java
new file mode 100644
index 0000000000..b04f94ec3b
--- /dev/null
+++ b/server/plugins/connect-plugin-postgres/src/main/java/com/oceanbase/odc/plugin/connect/postgres/PostgresDiagnoseExtensionPoint.java
@@ -0,0 +1,188 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.plugin.connect.postgres;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.List;
+
+import org.pf4j.Extension;
+
+import com.oceanbase.odc.common.util.tableformat.BorderStyle;
+import com.oceanbase.odc.common.util.tableformat.CellStyle;
+import com.oceanbase.odc.common.util.tableformat.CellStyle.AbbreviationStyle;
+import com.oceanbase.odc.common.util.tableformat.CellStyle.HorizontalAlign;
+import com.oceanbase.odc.common.util.tableformat.CellStyle.NullStyle;
+import com.oceanbase.odc.common.util.tableformat.Table;
+import com.oceanbase.odc.core.shared.constant.ErrorCodes;
+import com.oceanbase.odc.core.shared.exception.OBException;
+import com.oceanbase.odc.core.shared.model.SqlExecDetail;
+import com.oceanbase.odc.plugin.connect.api.SqlDiagnoseExtensionPoint;
+import com.oceanbase.odc.plugin.connect.model.diagnose.SqlExplain;
+
+import lombok.NonNull;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * PostgreSQL SQL 诊断扩展点实现
+ *
+ *
+ * PostgreSQL 不支持通过 Trace ID 获取查询 Profile 的方式, 该方法始终抛出 {@link UnsupportedOperationException}。
+ *
+ * @param connection JDBC Connection 对象
+ * @param traceId Trace 标识符(不支持)
+ * @param sessionIds 会话 ID 列表(不支持)
+ * @return 不返回,始终抛出异常
+ * @throws UnsupportedOperationException PostgreSQL 不支持此功能
+ */
+ @Override
+ public SqlExplain getQueryProfileByTraceIdAndSessIds(Connection connection, @NonNull String traceId,
+ @NonNull List sessionIds) throws SQLException {
+ throw new UnsupportedOperationException("Not supported for PostgreSQL mode");
+ }
+}
diff --git a/server/plugins/connect-plugin-postgres/src/main/java/com/oceanbase/odc/plugin/connect/postgres/PostgresTraceExtension.java b/server/plugins/connect-plugin-postgres/src/main/java/com/oceanbase/odc/plugin/connect/postgres/PostgresTraceExtension.java
new file mode 100644
index 0000000000..15c5069ed6
--- /dev/null
+++ b/server/plugins/connect-plugin-postgres/src/main/java/com/oceanbase/odc/plugin/connect/postgres/PostgresTraceExtension.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.plugin.connect.postgres;
+
+import java.sql.SQLException;
+import java.sql.Statement;
+
+import org.pf4j.Extension;
+
+import com.oceanbase.odc.core.sql.execute.model.SqlExecTime;
+import com.oceanbase.odc.plugin.connect.api.TraceExtensionPoint;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * PostgreSQL Trace 扩展点实现
+ *
+ *
+ * 实现与 SQLServer 保持一致,都返回空对象占位。
+ *
+ * @param statement JDBC Statement 对象
+ * @param version PostgreSQL 版本号
+ * @return 空的 SqlExecTime 对象
+ * @throws SQLException 不会抛出,仅接口要求
+ */
+ @Override
+ public SqlExecTime getExecuteDetail(Statement statement, String version) throws SQLException {
+ SqlExecTime sqlExecTime = new SqlExecTime();
+ // PostgreSQL does not provide built-in trace mechanism like OceanBase
+ // to get detailed execution time information.
+ // Return empty SqlExecTime object as placeholder (consistent with SQLServer implementation)
+ //
+ // Future options for getting execution time info:
+ // 1. Use EXPLAIN ANALYZE to get actual execution time (but it executes the SQL)
+ // 2. Use pg_stat_statements extension to get historical query statistics
+ return sqlExecTime;
+ }
+}
diff --git a/server/plugins/connect-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/connect/postgres/PostgresDiagnoseExtensionPointTest.java b/server/plugins/connect-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/connect/postgres/PostgresDiagnoseExtensionPointTest.java
new file mode 100644
index 0000000000..f47d1bd7ee
--- /dev/null
+++ b/server/plugins/connect-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/connect/postgres/PostgresDiagnoseExtensionPointTest.java
@@ -0,0 +1,435 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.plugin.connect.postgres;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.when;
+
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.Arrays;
+import java.util.Collections;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import com.oceanbase.odc.core.shared.exception.OBException;
+import com.oceanbase.odc.plugin.connect.model.diagnose.SqlExplain;
+
+/**
+ * {@link PostgresDiagnoseExtensionPoint} 单元测试
+ *
+ *
+ */
+ private boolean hasStructuralChange(DBTableIndex oldIndex, DBTableIndex newIndex) {
+ // 比较索引类型
+ if (!Objects.equals(oldIndex.getType(), newIndex.getType())) {
+ return true;
+ }
+
+ // 比较列名
+ if (!Objects.equals(oldIndex.getColumnNames(), newIndex.getColumnNames())) {
+ return true;
+ }
+
+ // 比较唯一性
+ if (!Objects.equals(oldIndex.getUnique(), newIndex.getUnique())
+ || oldIndex.isNonUnique() != newIndex.isNonUnique()) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * 获取 PostgreSQL 索引方法名
+ */
+ private String getIndexMethod(DBTableIndex index) {
+ if (index.getType() == null) {
+ return "btree"; // 默认使用 btree
+ }
+ switch (index.getType()) {
+ case UNIQUE:
+ return "btree"; // UNIQUE INDEX 默认也使用 btree
+ case FULLTEXT:
+ return "gin"; // 全文索引使用 gin
+ case NORMAL:
+ default:
+ return "btree";
+ }
+ }
+
+ /**
+ * 构建索引结构键,用于匹配重命名的索引
+ */
+ private String buildIndexStructureKey(DBTableIndex index) {
+ if (index == null) {
+ return null;
+ }
+ StringBuilder key = new StringBuilder();
+ key.append(index.getType() != null ? index.getType().name() : "NORMAL");
+ key.append("|");
+ if (index.getColumnNames() != null) {
+ key.append(String.join(",", index.getColumnNames()));
+ }
+ key.append("|");
+ key.append(index.getUnique() != null ? index.getUnique() : false);
+ return key.toString();
+ }
+
+ /**
+ * 检查索引是否被重命名
+ */
+ private boolean isIndexRenamed(DBTableIndex oldIndex, Collection newIndexes) {
+ String oldStructureKey = buildIndexStructureKey(oldIndex);
+ if (StringUtils.isEmpty(oldStructureKey)) {
+ return false;
+ }
+ for (DBTableIndex newIndex : newIndexes) {
+ String newStructureKey = buildIndexStructureKey(newIndex);
+ if (oldStructureKey.equals(newStructureKey)
+ && !StringUtils.equals(oldIndex.getName(), newIndex.getName())) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/postgre/PostgresObjectOperator.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/postgre/PostgresObjectOperator.java
new file mode 100644
index 0000000000..156a4dacc4
--- /dev/null
+++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/postgre/PostgresObjectOperator.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.tools.dbbrowser.editor.postgre;
+
+import org.springframework.jdbc.core.JdbcOperations;
+
+import com.oceanbase.tools.dbbrowser.editor.DBObjectOperator;
+import com.oceanbase.tools.dbbrowser.editor.GeneralSqlStatementBuilder;
+import com.oceanbase.tools.dbbrowser.model.DBObjectType;
+import com.oceanbase.tools.dbbrowser.util.PostgresSqlBuilder;
+
+/**
+ * PostgreSQL 数据库对象操作器
+ *
+ *
+ * 提供删除数据库对象的能力,使用 PostgreSQL 特有的 SQL 语法。
+ *
+ *
+ * @author odc
+ * @since ODC_release_4.3.4
+ */
+public class PostgresObjectOperator implements DBObjectOperator {
+
+ protected final JdbcOperations syncJdbcExecutor;
+
+ public PostgresObjectOperator(JdbcOperations syncJdbcExecutor) {
+ this.syncJdbcExecutor = syncJdbcExecutor;
+ }
+
+ @Override
+ public void drop(DBObjectType objectType, String schemaName, String objectName) {
+ String sql = GeneralSqlStatementBuilder.drop(new PostgresSqlBuilder(), objectType, schemaName, objectName);
+ syncJdbcExecutor.execute(sql);
+ }
+
+}
diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/postgre/PostgresPartitionEditor.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/postgre/PostgresPartitionEditor.java
new file mode 100644
index 0000000000..fc008f1500
--- /dev/null
+++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/editor/postgre/PostgresPartitionEditor.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.tools.dbbrowser.editor.postgre;
+
+import java.util.List;
+
+import javax.validation.constraints.NotNull;
+
+import com.oceanbase.tools.dbbrowser.editor.DBTablePartitionEditor;
+import com.oceanbase.tools.dbbrowser.model.DBTablePartition;
+import com.oceanbase.tools.dbbrowser.model.DBTablePartitionDefinition;
+import com.oceanbase.tools.dbbrowser.model.DBTablePartitionOption;
+import com.oceanbase.tools.dbbrowser.util.PostgresSqlBuilder;
+import com.oceanbase.tools.dbbrowser.util.SqlBuilder;
+import com.oceanbase.tools.dbbrowser.util.StringUtils;
+
+/**
+ * PostgreSQL 分区编辑器
+ *
+ *
+ * PostgreSQL 声明式分区语法特点:
+ *
+ *
+ *
创建分区表:CREATE TABLE ... PARTITION BY RANGE/LIST/HASH (column);
+ *
+ * @param param the parameter to format
+ * @return formatted parameter string
+ */
+ private String formatParameter(DBPLParam param) {
+ StringBuilder sb = new StringBuilder();
+
+ // Add parameter mode (IN/OUT/INOUT) if specified
+ DBPLParamMode paramMode = param.getParamMode();
+ if (Objects.nonNull(paramMode) && paramMode != DBPLParamMode.UNKNOWN) {
+ sb.append(paramMode.name()).append(" ");
+ }
+
+ // Add parameter name (handle special characters)
+ sb.append(StringUtils.quoteOracleIdentifier(param.getParamName()));
+
+ // Add data type
+ if (StringUtils.isNotBlank(param.getDataType())) {
+ sb.append(" ").append(param.getDataType());
+ } else {
+ sb.append(" INTEGER"); // Default type
+ }
+
+ // Add default value if specified (only for IN parameters)
+ if (StringUtils.isNotBlank(param.getDefaultValue())
+ && (paramMode == null || paramMode == DBPLParamMode.IN)) {
+ sb.append(" DEFAULT ").append(param.getDefaultValue());
+ }
+
+ return sb.toString();
+ }
+
+}
diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/postgre/PostgresProcedureTemplate.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/postgre/PostgresProcedureTemplate.java
new file mode 100644
index 0000000000..819ca63181
--- /dev/null
+++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/postgre/PostgresProcedureTemplate.java
@@ -0,0 +1,147 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.tools.dbbrowser.template.postgre;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import javax.validation.constraints.NotNull;
+
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.commons.lang3.Validate;
+
+import com.oceanbase.tools.dbbrowser.model.DBPLParam;
+import com.oceanbase.tools.dbbrowser.model.DBPLParamMode;
+import com.oceanbase.tools.dbbrowser.model.DBProcedure;
+import com.oceanbase.tools.dbbrowser.template.DBObjectTemplate;
+import com.oceanbase.tools.dbbrowser.util.PostgresSqlBuilder;
+import com.oceanbase.tools.dbbrowser.util.SqlBuilder;
+import com.oceanbase.tools.dbbrowser.util.StringUtils;
+
+/**
+ * PostgreSQL procedure template for generating CREATE PROCEDURE statements.
+ *
+ *
+ * PostgreSQL 11+ supports stored procedures. Like functions, procedures use PL/pgSQL as the
+ * procedural language and dollar-quoting for the body.
+ *
+ *
+ *
+ * Template output example:
+ *
+ *
+ *
+ * CREATE OR REPLACE PROCEDURE "procedure_name" (
+ * p_param1 INTEGER
+ * )
+ * LANGUAGE plpgsql
+ * AS $$
+ * BEGIN
+ * -- procedure body
+ * NULL;
+ * END;
+ * $$;
+ *
+ *
+ *
+ * Note: CREATE PROCEDURE is available in PostgreSQL 11 and later versions.
+ *
+ *
+ * @author odc
+ * @since ODC_release_4.3.4
+ */
+public class PostgresProcedureTemplate implements DBObjectTemplate {
+
+ @Override
+ public String generateCreateObjectTemplate(@NotNull DBProcedure dbObject) {
+ Validate.notBlank(dbObject.getProName(), "Procedure name can not be blank");
+
+ SqlBuilder sqlBuilder = new PostgresSqlBuilder();
+
+ // Generate CREATE OR REPLACE PROCEDURE
+ sqlBuilder.append("CREATE OR REPLACE PROCEDURE ").identifier(dbObject.getProName()).append(" (");
+
+ // Generate parameters
+ List paramList = dbObject.getParams();
+ if (CollectionUtils.isNotEmpty(paramList)) {
+ String params = paramList.stream()
+ .map(p -> formatParameter(p))
+ .collect(Collectors.joining(",\n\t"));
+ sqlBuilder.append("\n\t").append(params).append("\n");
+ }
+
+ sqlBuilder.append(")").line();
+
+ // Generate LANGUAGE clause
+ sqlBuilder.append("LANGUAGE plpgsql").line();
+
+ // Generate procedure body using dollar-quoting
+ sqlBuilder.append("AS $$").line();
+ sqlBuilder.append("BEGIN").line();
+ sqlBuilder.append("\t-- Enter your procedure code here").line();
+ sqlBuilder.append("\tNULL;").line();
+ sqlBuilder.append("END;").line();
+ sqlBuilder.append("$$;");
+
+ return sqlBuilder.toString();
+ }
+
+ /**
+ * Format a single PL/pgSQL procedure parameter.
+ *
+ *
+ * Note: Unlike functions, procedures support OUT parameters but they work differently - they are
+ * assigned values within the procedure body.
+ *
+ *
+ * @param param the parameter to format
+ * @return formatted parameter string
+ */
+ private String formatParameter(DBPLParam param) {
+ StringBuilder sb = new StringBuilder();
+
+ // Add parameter mode (IN/OUT/INOUT) if specified
+ DBPLParamMode paramMode = param.getParamMode();
+ if (Objects.nonNull(paramMode) && paramMode != DBPLParamMode.UNKNOWN) {
+ sb.append(paramMode.name()).append(" ");
+ }
+
+ // Add parameter name (handle special characters)
+ sb.append(StringUtils.quoteOracleIdentifier(param.getParamName()));
+
+ // Add data type
+ if (StringUtils.isNotBlank(param.getDataType())) {
+ sb.append(" ").append(param.getDataType());
+ } else {
+ sb.append(" INTEGER"); // Default type
+ }
+
+ // Add default value if specified (only for IN parameters)
+ if (StringUtils.isNotBlank(param.getDefaultValue())
+ && (paramMode == null || paramMode == DBPLParamMode.IN)) {
+ sb.append(" DEFAULT ").append(param.getDefaultValue());
+ }
+
+ return sb.toString();
+ }
+
+}
diff --git a/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/postgre/PostgresViewTemplate.java b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/postgre/PostgresViewTemplate.java
new file mode 100644
index 0000000000..c45c19dab8
--- /dev/null
+++ b/libs/db-browser/src/main/java/com/oceanbase/tools/dbbrowser/template/postgre/PostgresViewTemplate.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.tools.dbbrowser.template.postgre;
+
+import javax.validation.constraints.NotNull;
+
+import org.apache.commons.lang3.Validate;
+
+import com.oceanbase.tools.dbbrowser.model.DBView;
+import com.oceanbase.tools.dbbrowser.template.BaseViewTemplate;
+import com.oceanbase.tools.dbbrowser.util.PostgresSqlBuilder;
+import com.oceanbase.tools.dbbrowser.util.SqlBuilder;
+import com.oceanbase.tools.dbbrowser.util.StringUtils;
+
+/**
+ * PostgreSQL view template for generating CREATE VIEW statements.
+ *
+ *
+ * PostgreSQL uses double quotes for identifiers and supports CREATE OR REPLACE VIEW syntax. Unlike
+ * SQL Server, PostgreSQL does not require USE statement before CREATE VIEW.
+ *
+ *
+ *
+ * Template output example:
+ *
+ *
+ *
+ * CREATE OR REPLACE VIEW "schema_name"."view_name" AS
+ * SELECT column1, column2
+ * FROM "schema_name"."table_name"
+ * WHERE condition;
+ *
+ * 返回 PostgreSQL 专用的函数模板。
+ *
+ * @return PostgreSQL 函数模板实例
+ */
+ @Override
+ protected DBObjectTemplate getTemplate() {
+ return DBBrowser.objectTemplate().functionTemplate()
+ .setType(DialectType.POSTGRESQL.getDBBrowserDialectTypeName()).create();
+ }
+
+}
diff --git a/server/plugins/schema-plugin-postgres/src/main/java/com/oceanbase/odc/plugin/schema/postgres/PostgresProcedureExtension.java b/server/plugins/schema-plugin-postgres/src/main/java/com/oceanbase/odc/plugin/schema/postgres/PostgresProcedureExtension.java
new file mode 100644
index 0000000000..1349ac0a00
--- /dev/null
+++ b/server/plugins/schema-plugin-postgres/src/main/java/com/oceanbase/odc/plugin/schema/postgres/PostgresProcedureExtension.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.plugin.schema.postgres;
+
+import java.sql.Connection;
+
+import org.pf4j.Extension;
+
+import com.oceanbase.odc.common.util.JdbcOperationsUtil;
+import com.oceanbase.odc.core.shared.constant.DialectType;
+import com.oceanbase.odc.plugin.schema.obmysql.OBMySQLProcedureExtension;
+import com.oceanbase.odc.plugin.schema.postgres.utils.DBAccessorUtil;
+import com.oceanbase.tools.dbbrowser.DBBrowser;
+import com.oceanbase.tools.dbbrowser.editor.DBObjectOperator;
+import com.oceanbase.tools.dbbrowser.editor.postgre.PostgresObjectOperator;
+import com.oceanbase.tools.dbbrowser.model.DBProcedure;
+import com.oceanbase.tools.dbbrowser.schema.DBSchemaAccessor;
+import com.oceanbase.tools.dbbrowser.template.DBObjectTemplate;
+
+import lombok.NonNull;
+
+/**
+ * PostgreSQL 数据库存储过程扩展实现
+ *
+ *
+ * PostgreSQL 暂不支持此功能。
+ *
+ * @param connection 数据库连接
+ * @param schemaName schema 名称
+ * @param tableName 表名
+ * @return 不支持
+ */
@Override
public boolean syncExternalTableFiles(Connection connection, String schemaName, String tableName) {
- throw new UnsupportedOperationException("not implemented yet");
+ throw new UnsupportedOperationException("PostgreSQL does not support external table file sync");
}
}
diff --git a/server/plugins/schema-plugin-postgres/src/main/java/com/oceanbase/odc/plugin/schema/postgres/PostgresViewExtension.java b/server/plugins/schema-plugin-postgres/src/main/java/com/oceanbase/odc/plugin/schema/postgres/PostgresViewExtension.java
new file mode 100644
index 0000000000..5563395fde
--- /dev/null
+++ b/server/plugins/schema-plugin-postgres/src/main/java/com/oceanbase/odc/plugin/schema/postgres/PostgresViewExtension.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.plugin.schema.postgres;
+
+import java.sql.Connection;
+
+import org.pf4j.Extension;
+
+import com.oceanbase.odc.common.util.JdbcOperationsUtil;
+import com.oceanbase.odc.core.shared.constant.DialectType;
+import com.oceanbase.odc.plugin.schema.obmysql.OBMySQLViewExtension;
+import com.oceanbase.odc.plugin.schema.postgres.utils.DBAccessorUtil;
+import com.oceanbase.tools.dbbrowser.DBBrowser;
+import com.oceanbase.tools.dbbrowser.editor.DBObjectOperator;
+import com.oceanbase.tools.dbbrowser.editor.postgre.PostgresObjectOperator;
+import com.oceanbase.tools.dbbrowser.model.DBView;
+import com.oceanbase.tools.dbbrowser.schema.DBSchemaAccessor;
+import com.oceanbase.tools.dbbrowser.template.DBObjectTemplate;
+
+import lombok.NonNull;
+
+/**
+ * PostgreSQL 数据库视图扩展实现
+ *
+ *
+ * 返回 PostgreSQL 专用的视图模板。
+ *
+ * @return PostgreSQL 视图模板实例
+ */
+ @Override
+ protected DBObjectTemplate getTemplate() {
+ return DBBrowser.objectTemplate().viewTemplate()
+ .setType(DialectType.POSTGRESQL.getDBBrowserDialectTypeName()).create();
+ }
+
+}
diff --git a/server/plugins/schema-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/schema/postgres/PostgresFunctionExtensionTest.java b/server/plugins/schema-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/schema/postgres/PostgresFunctionExtensionTest.java
new file mode 100644
index 0000000000..1e93017f25
--- /dev/null
+++ b/server/plugins/schema-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/schema/postgres/PostgresFunctionExtensionTest.java
@@ -0,0 +1,198 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.plugin.schema.postgres;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.sql.Connection;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import com.oceanbase.tools.dbbrowser.model.DBFunction;
+import com.oceanbase.tools.dbbrowser.model.DBPLParam;
+import com.oceanbase.tools.dbbrowser.model.DBPLParamMode;
+
+/**
+ * {@link PostgresFunctionExtension} 单元测试
+ *
+ *
+ * 测试覆盖:
+ *
+ *
generateCreateTemplate() 方法生成正确的 PostgreSQL 函数模板
+ *
验证 PostgreSQL 特有语法:dollar-quoting
+ *
+ *
+ * @author ODC Team
+ * @since ODC_release_4.3.5
+ */
+public class PostgresFunctionExtensionTest {
+
+ private PostgresFunctionExtension functionExtension;
+
+ @Mock
+ private Connection connection;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+ functionExtension = new PostgresFunctionExtension();
+ }
+
+ /**
+ * 创建测试用的函数对象
+ */
+ private DBFunction createTestFunction() {
+ DBFunction function = new DBFunction();
+ function.setFunName("calculate_total");
+
+ List params = new ArrayList<>();
+
+ DBPLParam param1 = new DBPLParam();
+ param1.setParamName("price");
+ param1.setDataType("NUMERIC");
+ param1.setSeqNum(1);
+ param1.setParamMode(DBPLParamMode.IN);
+ params.add(param1);
+
+ DBPLParam param2 = new DBPLParam();
+ param2.setParamName("quantity");
+ param2.setDataType("INTEGER");
+ param2.setSeqNum(2);
+ param2.setParamMode(DBPLParamMode.IN);
+ params.add(param2);
+
+ function.setParams(params);
+ function.setReturnType("NUMERIC");
+
+ return function;
+ }
+
+ // ==================== generateCreateTemplate 测试 ====================
+
+ /**
+ * 测试用例:生成 CREATE FUNCTION 模板 - 基本场景
+ */
+ @Test
+ public void test_generateCreateTemplate_Basic() {
+ DBFunction function = createTestFunction();
+ String template = functionExtension.generateCreateTemplate(function);
+
+ assertNotNull("Template should not be null", template);
+ // 模板生成包含函数名
+ assertTrue("Template should contain function name",
+ template.contains("calculate_total"));
+ }
+
+ /**
+ * 测试用例:生成 CREATE FUNCTION 模板 - 包含参数
+ */
+ @Test
+ public void test_generateCreateTemplate_WithParameters() {
+ DBFunction function = createTestFunction();
+ String template = functionExtension.generateCreateTemplate(function);
+
+ assertNotNull("Template should not be null", template);
+ assertTrue("Template should contain parameters",
+ template.contains("price") && template.contains("quantity"));
+ }
+
+ /**
+ * 测试用例:生成 CREATE FUNCTION 模板 - 包含返回类型
+ */
+ @Test
+ public void test_generateCreateTemplate_WithReturnType() {
+ DBFunction function = createTestFunction();
+ String template = functionExtension.generateCreateTemplate(function);
+
+ assertNotNull("Template should not be null", template);
+ // PostgreSQL 使用 RETURNS 关键字指定返回类型
+ assertTrue("Template should contain RETURNS keyword", template.contains("RETURNS"));
+ }
+
+ /**
+ * 测试用例:生成 CREATE FUNCTION 模板 - 包含 dollar-quoting
+ */
+ @Test
+ public void test_generateCreateTemplate_WithDollarQuoting() {
+ DBFunction function = createTestFunction();
+ String template = functionExtension.generateCreateTemplate(function);
+
+ assertNotNull("Template should not be null", template);
+ // PostgreSQL 使用 dollar-quoting ($$...$$) 包裹函数体
+ assertTrue("Template should contain dollar-quoting", template.contains("$$"));
+ }
+
+ /**
+ * 测试用例:生成 CREATE FUNCTION 模板 - PL/pgSQL 语言
+ */
+ @Test
+ public void test_generateCreateTemplate_PlPgSQL() {
+ DBFunction function = createTestFunction();
+ String template = functionExtension.generateCreateTemplate(function);
+
+ assertNotNull("Template should not be null", template);
+ // PostgreSQL 默认使用 PL/pgSQL 语言
+ assertTrue("Template should contain LANGUAGE", template.contains("LANGUAGE"));
+ }
+
+ /**
+ * 测试用例:生成 CREATE OR REPLACE FUNCTION 模板
+ */
+ @Test
+ public void test_generateCreateTemplate_OrReplace() {
+ DBFunction function = createTestFunction();
+ String template = functionExtension.generateCreateTemplate(function);
+
+ assertNotNull("Template should not be null", template);
+ // PostgreSQL 支持 CREATE OR REPLACE FUNCTION
+ assertTrue("Template should contain CREATE OR REPLACE",
+ template.contains("CREATE OR REPLACE"));
+ }
+
+ /**
+ * 测试用例:生成 CREATE FUNCTION 模板 - 无参数
+ */
+ @Test
+ public void test_generateCreateTemplate_NoParameters() {
+ DBFunction function = new DBFunction();
+ function.setFunName("get_current_time");
+ function.setReturnType("TIMESTAMP");
+ function.setParams(new ArrayList<>());
+
+ String template = functionExtension.generateCreateTemplate(function);
+
+ assertNotNull("Template should not be null", template);
+ assertTrue("Template should contain function name", template.contains("get_current_time"));
+ }
+
+ // ==================== 继承关系测试 ====================
+
+ /**
+ * 测试用例:验证函数扩展类继承关系
+ */
+ @Test
+ public void test_inheritance() {
+ assertTrue("PostgresFunctionExtension should extend OBMySQLFunctionExtension",
+ functionExtension instanceof com.oceanbase.odc.plugin.schema.obmysql.OBMySQLFunctionExtension);
+ }
+
+}
diff --git a/server/plugins/schema-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/schema/postgres/PostgresProcedureExtensionTest.java b/server/plugins/schema-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/schema/postgres/PostgresProcedureExtensionTest.java
new file mode 100644
index 0000000000..76d11b6db7
--- /dev/null
+++ b/server/plugins/schema-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/schema/postgres/PostgresProcedureExtensionTest.java
@@ -0,0 +1,222 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.plugin.schema.postgres;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.sql.Connection;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import com.oceanbase.tools.dbbrowser.model.DBPLParam;
+import com.oceanbase.tools.dbbrowser.model.DBPLParamMode;
+import com.oceanbase.tools.dbbrowser.model.DBProcedure;
+
+/**
+ * {@link PostgresProcedureExtension} 单元测试
+ *
+ *
+ *
+ * @author ODC Team
+ * @since ODC_release_4.3.5
+ */
+public class PostgresProcedureExtensionTest {
+
+ private PostgresProcedureExtension procedureExtension;
+
+ @Mock
+ private Connection connection;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+ procedureExtension = new PostgresProcedureExtension();
+ }
+
+ /**
+ * 创建测试用的存储过程对象
+ */
+ private DBProcedure createTestProcedure() {
+ DBProcedure procedure = new DBProcedure();
+ procedure.setProName("transfer_funds");
+
+ List params = new ArrayList<>();
+
+ DBPLParam param1 = new DBPLParam();
+ param1.setParamName("from_account");
+ param1.setDataType("INTEGER");
+ param1.setSeqNum(1);
+ param1.setParamMode(DBPLParamMode.IN);
+ params.add(param1);
+
+ DBPLParam param2 = new DBPLParam();
+ param2.setParamName("to_account");
+ param2.setDataType("INTEGER");
+ param2.setSeqNum(2);
+ param2.setParamMode(DBPLParamMode.IN);
+ params.add(param2);
+
+ DBPLParam param3 = new DBPLParam();
+ param3.setParamName("amount");
+ param3.setDataType("NUMERIC");
+ param3.setSeqNum(3);
+ param3.setParamMode(DBPLParamMode.IN);
+ params.add(param3);
+
+ procedure.setParams(params);
+
+ return procedure;
+ }
+
+ // ==================== generateCreateTemplate 测试 ====================
+
+ /**
+ * 测试用例:生成 CREATE PROCEDURE 模板 - 基本场景
+ */
+ @Test
+ public void test_generateCreateTemplate_Basic() {
+ DBProcedure procedure = createTestProcedure();
+ String template = procedureExtension.generateCreateTemplate(procedure);
+
+ assertNotNull("Template should not be null", template);
+ // 模板生成包含过程名
+ assertTrue("Template should contain procedure name",
+ template.contains("transfer_funds"));
+ }
+
+ /**
+ * 测试用例:生成 CREATE PROCEDURE 模板 - 包含参数
+ */
+ @Test
+ public void test_generateCreateTemplate_WithParameters() {
+ DBProcedure procedure = createTestProcedure();
+ String template = procedureExtension.generateCreateTemplate(procedure);
+
+ assertNotNull("Template should not be null", template);
+ assertTrue("Template should contain parameters",
+ template.contains("from_account") && template.contains("to_account") && template.contains("amount"));
+ }
+
+ /**
+ * 测试用例:生成 CREATE PROCEDURE 模板 - 包含 dollar-quoting
+ */
+ @Test
+ public void test_generateCreateTemplate_WithDollarQuoting() {
+ DBProcedure procedure = createTestProcedure();
+ String template = procedureExtension.generateCreateTemplate(procedure);
+
+ assertNotNull("Template should not be null", template);
+ // PostgreSQL 使用 dollar-quoting ($$...$$) 包裹过程体
+ assertTrue("Template should contain dollar-quoting", template.contains("$$"));
+ }
+
+ /**
+ * 测试用例:生成 CREATE PROCEDURE 模板 - PL/pgSQL 语言
+ */
+ @Test
+ public void test_generateCreateTemplate_PlPgSQL() {
+ DBProcedure procedure = createTestProcedure();
+ String template = procedureExtension.generateCreateTemplate(procedure);
+
+ assertNotNull("Template should not be null", template);
+ // PostgreSQL 默认使用 PL/pgSQL 语言
+ assertTrue("Template should contain LANGUAGE", template.contains("LANGUAGE"));
+ }
+
+ /**
+ * 测试用例:生成 CREATE OR REPLACE PROCEDURE 模板
+ */
+ @Test
+ public void test_generateCreateTemplate_OrReplace() {
+ DBProcedure procedure = createTestProcedure();
+ String template = procedureExtension.generateCreateTemplate(procedure);
+
+ assertNotNull("Template should not be null", template);
+ // PostgreSQL 支持 CREATE OR REPLACE PROCEDURE (PG 11+)
+ assertTrue("Template should contain CREATE OR REPLACE",
+ template.contains("CREATE OR REPLACE"));
+ }
+
+ /**
+ * 测试用例:生成 CREATE PROCEDURE 模板 - 无参数
+ */
+ @Test
+ public void test_generateCreateTemplate_NoParameters() {
+ DBProcedure procedure = new DBProcedure();
+ procedure.setProName("cleanup_logs");
+ procedure.setParams(new ArrayList<>());
+
+ String template = procedureExtension.generateCreateTemplate(procedure);
+
+ assertNotNull("Template should not be null", template);
+ assertTrue("Template should contain procedure name", template.contains("cleanup_logs"));
+ }
+
+ /**
+ * 测试用例:生成 CREATE PROCEDURE 模板 - 包含 IN/OUT 参数
+ */
+ @Test
+ public void test_generateCreateTemplate_WithInOutParameters() {
+ DBProcedure procedure = new DBProcedure();
+ procedure.setProName("get_user_info");
+
+ List params = new ArrayList<>();
+
+ DBPLParam param1 = new DBPLParam();
+ param1.setParamName("user_id");
+ param1.setDataType("INTEGER");
+ param1.setParamMode(DBPLParamMode.IN);
+ param1.setSeqNum(1);
+ params.add(param1);
+
+ DBPLParam param2 = new DBPLParam();
+ param2.setParamName("user_name");
+ param2.setDataType("VARCHAR");
+ param2.setParamMode(DBPLParamMode.OUT);
+ param2.setSeqNum(2);
+ params.add(param2);
+
+ procedure.setParams(params);
+ String template = procedureExtension.generateCreateTemplate(procedure);
+
+ assertNotNull("Template should not be null", template);
+ assertTrue("Template should contain IN parameter", template.contains("user_id"));
+ assertTrue("Template should contain OUT parameter", template.contains("user_name"));
+ }
+
+ // ==================== 继承关系测试 ====================
+
+ /**
+ * 测试用例:验证存储过程扩展类继承关系
+ */
+ @Test
+ public void test_inheritance() {
+ assertTrue("PostgresProcedureExtension should extend OBMySQLProcedureExtension",
+ procedureExtension instanceof com.oceanbase.odc.plugin.schema.obmysql.OBMySQLProcedureExtension);
+ }
+
+}
diff --git a/server/plugins/schema-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/schema/postgres/PostgresTableExtensionTest.java b/server/plugins/schema-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/schema/postgres/PostgresTableExtensionTest.java
new file mode 100644
index 0000000000..cc7f046d75
--- /dev/null
+++ b/server/plugins/schema-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/schema/postgres/PostgresTableExtensionTest.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.plugin.schema.postgres;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.sql.Connection;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+/**
+ * {@link PostgresTableExtension} 单元测试
+ *
+ *
+ * 测试覆盖:
+ *
+ *
继承关系验证
+ *
异常场景测试
+ *
+ *
+ * @author ODC Team
+ * @since ODC_release_4.3.5
+ */
+public class PostgresTableExtensionTest {
+
+ private PostgresTableExtension tableExtension;
+
+ @Mock
+ private Connection connection;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+ tableExtension = new PostgresTableExtension();
+ }
+
+ // ==================== 继承关系测试 ====================
+
+ /**
+ * 测试用例:验证表扩展类继承关系
+ */
+ @Test
+ public void test_inheritance() {
+ assertTrue("PostgresTableExtension should extend OBMySQLTableExtension",
+ tableExtension instanceof com.oceanbase.odc.plugin.schema.obmysql.OBMySQLTableExtension);
+ }
+
+ /**
+ * 测试用例:验证扩展类可以正常实例化
+ */
+ @Test
+ public void test_canInstantiate() {
+ assertNotNull("Extension should be instantiable", tableExtension);
+ }
+
+ // ==================== 异常场景测试 ====================
+
+ /**
+ * 测试用例:syncExternalTableFiles 应抛出 UnsupportedOperationException
+ */
+ @Test(expected = UnsupportedOperationException.class)
+ public void test_syncExternalTableFiles_ThrowsException() {
+ tableExtension.syncExternalTableFiles(connection, "public", "external_table");
+ }
+
+}
diff --git a/server/plugins/schema-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/schema/postgres/PostgresViewExtensionTest.java b/server/plugins/schema-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/schema/postgres/PostgresViewExtensionTest.java
new file mode 100644
index 0000000000..680206bd04
--- /dev/null
+++ b/server/plugins/schema-plugin-postgres/src/test/java/com/oceanbase/odc/plugin/schema/postgres/PostgresViewExtensionTest.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.plugin.schema.postgres;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+import java.sql.Connection;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import com.oceanbase.tools.dbbrowser.model.DBView;
+
+/**
+ * {@link PostgresViewExtension} 单元测试
+ *
+ *
+ * 测试覆盖:
+ *
+ *
generateCreateTemplate() 方法生成正确的 PostgreSQL 视图模板
+ *
验证 PostgreSQL 特有语法:小写关键字
+ *
+ *
+ * @author ODC Team
+ * @since ODC_release_4.3.5
+ */
+public class PostgresViewExtensionTest {
+
+ private PostgresViewExtension viewExtension;
+
+ @Mock
+ private Connection connection;
+
+ @Before
+ public void setUp() {
+ MockitoAnnotations.initMocks(this);
+ viewExtension = new PostgresViewExtension();
+ }
+
+ /**
+ * 创建测试用的视图对象
+ */
+ private DBView createTestView() {
+ DBView view = new DBView();
+ view.setSchemaName("public");
+ view.setViewName("test_view");
+ view.setDdl("SELECT id, name FROM test_table");
+ return view;
+ }
+
+ // ==================== generateCreateTemplate 测试 ====================
+
+ /**
+ * 测试用例:生成 CREATE VIEW 模板 - 基本场景
+ */
+ @Test
+ public void test_generateCreateTemplate_Basic() {
+ DBView view = createTestView();
+ String template = viewExtension.generateCreateTemplate(view);
+
+ assertNotNull("Template should not be null", template);
+ // PostgreSQL template uses lowercase keywords
+ assertTrue("Template should contain create view", template.toLowerCase().contains("create"));
+ assertTrue("Template should contain view name",
+ template.contains("test_view"));
+ }
+
+ /**
+ * 测试用例:生成 CREATE VIEW 模板 - 无 schema
+ */
+ @Test
+ public void test_generateCreateTemplate_NoSchema() {
+ DBView view = new DBView();
+ view.setViewName("simple_view");
+ String template = viewExtension.generateCreateTemplate(view);
+
+ assertNotNull("Template should not be null", template);
+ assertTrue("Template should contain create view", template.toLowerCase().contains("create"));
+ assertTrue("Template should contain view name", template.contains("simple_view"));
+ }
+
+ /**
+ * 测试用例:生成 CREATE OR REPLACE VIEW 模板
+ */
+ @Test
+ public void test_generateCreateTemplate_OrReplace() {
+ DBView view = createTestView();
+ String template = viewExtension.generateCreateTemplate(view);
+
+ assertNotNull("Template should not be null", template);
+ // PostgreSQL 支持 CREATE OR REPLACE VIEW
+ assertTrue("Template should contain create or replace",
+ template.toLowerCase().contains("create or replace"));
+ }
+
+ // ==================== 继承关系测试 ====================
+
+ /**
+ * 测试用例:验证视图扩展类继承关系
+ */
+ @Test
+ public void test_inheritance() {
+ assertTrue("PostgresViewExtension should extend OBMySQLViewExtension",
+ viewExtension instanceof com.oceanbase.odc.plugin.schema.obmysql.OBMySQLViewExtension);
+ }
+
+}
From bbea88795894d3af2d6113ba073c02079f100fe4 Mon Sep 17 00:00:00 2001
From: sjjian <921465802@qq.com>
Date: Thu, 12 Mar 2026 04:50:24 +0000
Subject: [PATCH 16/25] feat(postgres): implement PG JdbcColumnMapper for
PostgreSQL specific data types
- Add PGBooleanMapper: maps boolean/bool type to "true"/"false" string
- Add PGNumericMapper: ensures NUMERIC/DECIMAL precision with BigDecimal.toPlainString()
- Add PGByteaMapper: handles bytea binary data with size truncation display
- Add PGArrayMapper: handles PostgreSQL array types (_int4, _text, etc.)
- Add PGTimestampTZMapper: handles timestamptz type with timezone info
- Modify DefaultJdbcRowMapper: add PostgreSQL dialect branch with 5 mappers
- Add PGTestCellData test helper class
- Add unit tests for all 5 mappers (29 test cases total)
---
.../execute/mapper/DefaultJdbcRowMapper.java | 7 ++
.../sql/execute/mapper/PGArrayMapper.java | 71 ++++++++++++
.../sql/execute/mapper/PGBooleanMapper.java | 64 +++++++++++
.../sql/execute/mapper/PGByteaMapper.java | 75 +++++++++++++
.../sql/execute/mapper/PGNumericMapper.java | 57 ++++++++++
.../execute/mapper/PGTimestampTZMapper.java | 77 +++++++++++++
.../sql/execute/mapper/PGArrayMapperTest.java | 105 ++++++++++++++++++
.../execute/mapper/PGBooleanMapperTest.java | 88 +++++++++++++++
.../sql/execute/mapper/PGByteaMapperTest.java | 85 ++++++++++++++
.../execute/mapper/PGNumericMapperTest.java | 89 +++++++++++++++
.../mapper/PGTimestampTZMapperTest.java | 80 +++++++++++++
.../core/sql/execute/tool/PGTestCellData.java | 100 +++++++++++++++++
12 files changed, 898 insertions(+)
create mode 100644 server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/PGArrayMapper.java
create mode 100644 server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/PGBooleanMapper.java
create mode 100644 server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/PGByteaMapper.java
create mode 100644 server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/PGNumericMapper.java
create mode 100644 server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/PGTimestampTZMapper.java
create mode 100644 server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/PGArrayMapperTest.java
create mode 100644 server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/PGBooleanMapperTest.java
create mode 100644 server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/PGByteaMapperTest.java
create mode 100644 server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/PGNumericMapperTest.java
create mode 100644 server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/PGTimestampTZMapperTest.java
create mode 100644 server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/tool/PGTestCellData.java
diff --git a/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/DefaultJdbcRowMapper.java b/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/DefaultJdbcRowMapper.java
index e0ff3da5a5..2470442423 100644
--- a/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/DefaultJdbcRowMapper.java
+++ b/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/DefaultJdbcRowMapper.java
@@ -83,6 +83,13 @@ public DefaultJdbcRowMapper(@NonNull ConnectionSession session) {
ConnectionSessionUtil.getNlsTimestampTZFormat(session)));
mapperList.add(new OracleNlsFormatTimestampLTZMapper(
ConnectionSessionUtil.getNlsTimestampTZFormat(session)));
+ } else if (dialectType.isPostgreSql()) {
+ // PostgreSQL specific type mappers
+ mapperList.add(new PGBooleanMapper());
+ mapperList.add(new PGNumericMapper());
+ mapperList.add(new PGByteaMapper());
+ mapperList.add(new PGArrayMapper());
+ mapperList.add(new PGTimestampTZMapper());
}
mapperList.add(new GeneralLobMapper());
}
diff --git a/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/PGArrayMapper.java b/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/PGArrayMapper.java
new file mode 100644
index 0000000000..bbdbd0e908
--- /dev/null
+++ b/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/PGArrayMapper.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.core.sql.execute.mapper;
+
+import java.sql.Array;
+import java.sql.SQLException;
+
+import org.apache.commons.lang3.StringUtils;
+
+import com.oceanbase.tools.dbbrowser.model.datatype.DataType;
+
+import lombok.NonNull;
+
+/**
+ * {@link JdbcColumnMapper} for PostgreSQL array types
+ *
+ *
+ * Handles PostgreSQL array types and outputs in PostgreSQL array format: {@code {1,2,3}} for
+ * integer arrays, {@code {"a","b","c"}} for text arrays.
+ *
+ *
+ * PostgreSQL supports arrays of any built-in, user-defined, or enum type. Common array types
+ * include: {@code _int4}, {@code _int8}, {@code _text}, {@code _float8}, {@code _bool},
+ * {@code _numeric}, etc.
+ *
+ *
+ * The JDBC driver returns arrays via {@code getArray()}, and calling {@code toString()} on the
+ * Array object yields the PostgreSQL array literal format.
+ *
+ * @author ODC Team
+ * @date 2026-03-12
+ * @since ODC_release_4.3.0
+ * @see JdbcColumnMapper
+ */
+public class PGArrayMapper implements JdbcColumnMapper {
+
+ @Override
+ public Object mapCell(@NonNull CellData data) throws SQLException {
+ Array array = data.getArray();
+ if (array == null) {
+ return null;
+ }
+ // PostgreSQL JDBC driver returns the array in standard format {elem1,elem2,...}
+ return array.toString();
+ }
+
+ @Override
+ public boolean supports(@NonNull DataType dataType) {
+ // PostgreSQL array type names start with underscore (e.g., _int4, _text)
+ // Or contain [] suffix (e.g., int[], text[])
+ String typeName = dataType.getDataTypeName();
+ if (StringUtils.isEmpty(typeName)) {
+ return false;
+ }
+ return typeName.startsWith("_") || typeName.contains("[]");
+ }
+
+}
diff --git a/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/PGBooleanMapper.java b/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/PGBooleanMapper.java
new file mode 100644
index 0000000000..8a6d995f78
--- /dev/null
+++ b/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/PGBooleanMapper.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.core.sql.execute.mapper;
+
+import java.sql.SQLException;
+
+import com.oceanbase.tools.dbbrowser.model.datatype.DataType;
+
+import lombok.NonNull;
+
+/**
+ * {@link JdbcColumnMapper} for PostgreSQL data type {@code boolean}/{@code bool}
+ *
+ *
+ * Maps PostgreSQL boolean values to string representation "true" or "false". PostgreSQL has native
+ * boolean type with values TRUE/FALSE/NULL.
+ *
+ *
+ * Uses {@code getObject()} instead of {@code getBoolean()} to properly distinguish between null
+ * values and false, since {@code getBoolean()} returns primitive boolean which cannot represent
+ * null.
+ *
+ * @author ODC Team
+ * @date 2026-03-12
+ * @since ODC_release_4.3.0
+ * @see JdbcColumnMapper
+ */
+public class PGBooleanMapper implements JdbcColumnMapper {
+
+ @Override
+ public Object mapCell(@NonNull CellData data) throws SQLException {
+ // Use getObject() to properly handle null values
+ // getBoolean() returns primitive boolean which returns false for null
+ Object value = data.getObject();
+ if (value == null) {
+ return null;
+ }
+ if (value instanceof Boolean) {
+ return (Boolean) value ? "true" : "false";
+ }
+ // Fallback for other representations
+ return String.valueOf(value);
+ }
+
+ @Override
+ public boolean supports(@NonNull DataType dataType) {
+ String typeName = dataType.getDataTypeName();
+ return "BOOLEAN".equalsIgnoreCase(typeName) || "BOOL".equalsIgnoreCase(typeName);
+ }
+
+}
diff --git a/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/PGByteaMapper.java b/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/PGByteaMapper.java
new file mode 100644
index 0000000000..d44f33a6bd
--- /dev/null
+++ b/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/PGByteaMapper.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.core.sql.execute.mapper;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.sql.SQLException;
+
+import com.oceanbase.tools.dbbrowser.model.datatype.DataType;
+
+import lombok.NonNull;
+
+/**
+ * {@link JdbcColumnMapper} for PostgreSQL data type {@code bytea}
+ *
+ *
+ * Handles PostgreSQL binary data (bytea) with truncation display. Shows the size and unit
+ * (B/KB/MB/GB) instead of full binary content to improve display performance for large binary data.
+ *
+ *
+ * PostgreSQL bytea type stores binary strings. The hex format (introduced in PostgreSQL 9.0) is the
+ * default output format: {@code \x followed by hexadecimal digits}
+ *
+ * @author ODC Team
+ * @date 2026-03-12
+ * @since ODC_release_4.3.0
+ * @see JdbcColumnMapper
+ */
+public class PGByteaMapper implements JdbcColumnMapper {
+
+ private static final String BYTEA = "BYTEA";
+ private static final int KB = 1024;
+ private static final int MB = KB * 1024;
+ private static final int GB = MB * 1024;
+
+ @Override
+ public Object mapCell(@NonNull CellData data) throws SQLException, IOException {
+ InputStream inputStream = data.getBinaryStream();
+ if (inputStream == null) {
+ return null;
+ }
+ String unit = "B";
+ int available = inputStream.available();
+ if (available >= GB) {
+ available = available >> 30;
+ unit = "GB";
+ } else if (available >= MB) {
+ available = available >> 20;
+ unit = "MB";
+ } else if (available >= KB) {
+ available = available >> 10;
+ unit = "KB";
+ }
+ return String.format("(%s) %d %s", data.getDataType().getDataTypeName(), available, unit);
+ }
+
+ @Override
+ public boolean supports(@NonNull DataType dataType) {
+ return BYTEA.equalsIgnoreCase(dataType.getDataTypeName());
+ }
+
+}
diff --git a/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/PGNumericMapper.java b/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/PGNumericMapper.java
new file mode 100644
index 0000000000..21de13b061
--- /dev/null
+++ b/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/PGNumericMapper.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.core.sql.execute.mapper;
+
+import java.math.BigDecimal;
+import java.sql.SQLException;
+
+import org.apache.commons.lang3.StringUtils;
+
+import com.oceanbase.tools.dbbrowser.model.datatype.DataType;
+
+import lombok.NonNull;
+
+/**
+ * {@link JdbcColumnMapper} for PostgreSQL data type {@code numeric}/{@code decimal}
+ *
+ *
+ * Ensures NUMERIC/DECIMAL precision is preserved in string representation. PostgreSQL NUMERIC and
+ * DECIMAL types preserve exact precision and scale. Using BigDecimal.toString() ensures the full
+ * precision is maintained.
+ *
+ * @author ODC Team
+ * @date 2026-03-12
+ * @since ODC_release_4.3.0
+ * @see JdbcColumnMapper
+ */
+public class PGNumericMapper implements JdbcColumnMapper {
+
+ @Override
+ public Object mapCell(@NonNull CellData data) throws SQLException {
+ BigDecimal value = data.getBigDecimal();
+ if (value == null) {
+ return null;
+ }
+ return value.toPlainString();
+ }
+
+ @Override
+ public boolean supports(@NonNull DataType dataType) {
+ String typeName = dataType.getDataTypeName();
+ return StringUtils.containsAnyIgnoreCase(typeName, "NUMERIC", "DECIMAL");
+ }
+
+}
diff --git a/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/PGTimestampTZMapper.java b/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/PGTimestampTZMapper.java
new file mode 100644
index 0000000000..eea2baaff0
--- /dev/null
+++ b/server/odc-core/src/main/java/com/oceanbase/odc/core/sql/execute/mapper/PGTimestampTZMapper.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.core.sql.execute.mapper;
+
+import java.sql.SQLException;
+import java.sql.Timestamp;
+
+import org.apache.commons.lang3.StringUtils;
+
+import com.oceanbase.tools.dbbrowser.model.datatype.DataType;
+
+import lombok.NonNull;
+
+/**
+ * {@link JdbcColumnMapper} for PostgreSQL data type {@code timestamptz} (timestamp with time zone)
+ *
+ *
+ * Handles PostgreSQL timestamptz type and ensures timezone information is correctly formatted for
+ * display.
+ *
+ *
+ * PostgreSQL timestamptz stores timestamp with time zone information. The JDBC driver returns it as
+ * {@code java.sql.Timestamp}, shifted to the client's timezone. This mapper uses the string
+ * representation from JDBC which includes timezone offset.
+ *
+ *
+ * Note: For proper timezone handling, consider using {@code getTimestamp(Calendar)} with a specific
+ * calendar if needed.
+ *
+ * @author ODC Team
+ * @date 2026-03-12
+ * @since ODC_release_4.3.0
+ * @see JdbcColumnMapper
+ */
+public class PGTimestampTZMapper implements JdbcColumnMapper {
+
+ private static final String TIMESTAMPTZ = "TIMESTAMPTZ";
+ private static final String TIMESTAMP_WITH_TIME_ZONE = "TIMESTAMP WITH TIME ZONE";
+ private static final String TIMESTZ = "TIMESTZ";
+
+ @Override
+ public Object mapCell(@NonNull CellData data) throws SQLException {
+ Timestamp timestamp = data.getTimestamp();
+ if (timestamp == null) {
+ return null;
+ }
+ // Use Timestamp.toString() which includes nanoseconds
+ // The JDBC driver handles timezone conversion automatically
+ return timestamp.toString();
+ }
+
+ @Override
+ public boolean supports(@NonNull DataType dataType) {
+ String typeName = dataType.getDataTypeName();
+ if (StringUtils.isEmpty(typeName)) {
+ return false;
+ }
+ String upperTypeName = typeName.toUpperCase();
+ return TIMESTAMPTZ.equalsIgnoreCase(typeName)
+ || upperTypeName.contains("TIMESTAMP WITH TIME ZONE")
+ || TIMESTZ.equalsIgnoreCase(typeName);
+ }
+
+}
diff --git a/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/PGArrayMapperTest.java b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/PGArrayMapperTest.java
new file mode 100644
index 0000000000..d15cd0cf74
--- /dev/null
+++ b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/PGArrayMapperTest.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.core.sql.execute.mapper;
+
+import java.io.IOException;
+import java.sql.Array;
+import java.sql.SQLException;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import com.oceanbase.odc.core.sql.execute.tool.PGTestCellData;
+import com.oceanbase.tools.dbbrowser.model.datatype.CommonDataTypeFactory;
+import com.oceanbase.tools.dbbrowser.model.datatype.DataType;
+import com.oceanbase.tools.dbbrowser.model.datatype.DataTypeFactory;
+
+/**
+ * Test cases for {@link PGArrayMapper}
+ *
+ * @author ODC Team
+ * @date 2026-03-12
+ */
+public class PGArrayMapperTest {
+
+ @Test
+ public void mapCell_intArray_returnArrayString() throws IOException, SQLException {
+ DataTypeFactory factory = new CommonDataTypeFactory("_int4");
+ DataType dataType = factory.generate();
+ PGArrayMapper mapper = new PGArrayMapper();
+ PGTestCellData cellData = new PGTestCellData(dataType);
+
+ Array mockArray = Mockito.mock(Array.class);
+ Mockito.when(mockArray.toString()).thenReturn("{1,2,3}");
+ cellData.setArrayValue(mockArray);
+
+ Assert.assertEquals("{1,2,3}", mapper.mapCell(cellData));
+ }
+
+ @Test
+ public void mapCell_textArray_returnArrayString() throws IOException, SQLException {
+ DataTypeFactory factory = new CommonDataTypeFactory("_text");
+ DataType dataType = factory.generate();
+ PGArrayMapper mapper = new PGArrayMapper();
+ PGTestCellData cellData = new PGTestCellData(dataType);
+
+ Array mockArray = Mockito.mock(Array.class);
+ Mockito.when(mockArray.toString()).thenReturn("{a,b,c}");
+ cellData.setArrayValue(mockArray);
+
+ Assert.assertEquals("{a,b,c}", mapper.mapCell(cellData));
+ }
+
+ @Test
+ public void mapCell_nullArray_returnNull() throws IOException, SQLException {
+ DataTypeFactory factory = new CommonDataTypeFactory("_int4");
+ DataType dataType = factory.generate();
+ PGArrayMapper mapper = new PGArrayMapper();
+ PGTestCellData cellData = new PGTestCellData(dataType);
+ cellData.setArrayValue(null);
+ Assert.assertNull(mapper.mapCell(cellData));
+ }
+
+ @Test
+ public void supports_int4Array_supports() throws IOException, SQLException {
+ PGArrayMapper mapper = new PGArrayMapper();
+ DataTypeFactory factory = new CommonDataTypeFactory("_int4");
+ Assert.assertTrue(mapper.supports(factory.generate()));
+ }
+
+ @Test
+ public void supports_textArray_supports() throws IOException, SQLException {
+ PGArrayMapper mapper = new PGArrayMapper();
+ DataTypeFactory factory = new CommonDataTypeFactory("_text");
+ Assert.assertTrue(mapper.supports(factory.generate()));
+ }
+
+ @Test
+ public void supports_int8Array_supports() throws IOException, SQLException {
+ PGArrayMapper mapper = new PGArrayMapper();
+ DataTypeFactory factory = new CommonDataTypeFactory("_int8");
+ Assert.assertTrue(mapper.supports(factory.generate()));
+ }
+
+ @Test
+ public void supports_integer_notSupports() throws IOException, SQLException {
+ PGArrayMapper mapper = new PGArrayMapper();
+ DataTypeFactory factory = new CommonDataTypeFactory("integer");
+ Assert.assertFalse(mapper.supports(factory.generate()));
+ }
+
+}
diff --git a/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/PGBooleanMapperTest.java b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/PGBooleanMapperTest.java
new file mode 100644
index 0000000000..190c79220c
--- /dev/null
+++ b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/PGBooleanMapperTest.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.core.sql.execute.mapper;
+
+import java.io.IOException;
+import java.sql.SQLException;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import com.oceanbase.odc.core.sql.execute.tool.PGTestCellData;
+import com.oceanbase.tools.dbbrowser.model.datatype.CommonDataTypeFactory;
+import com.oceanbase.tools.dbbrowser.model.datatype.DataType;
+import com.oceanbase.tools.dbbrowser.model.datatype.DataTypeFactory;
+
+/**
+ * Test cases for {@link PGBooleanMapper}
+ *
+ * @author ODC Team
+ * @date 2026-03-12
+ */
+public class PGBooleanMapperTest {
+
+ @Test
+ public void mapCell_trueValue_returnTrueString() throws IOException, SQLException {
+ DataTypeFactory factory = new CommonDataTypeFactory("boolean");
+ DataType dataType = factory.generate();
+ PGBooleanMapper mapper = new PGBooleanMapper();
+ PGTestCellData cellData = new PGTestCellData(dataType);
+ cellData.setObjectValue(Boolean.TRUE);
+ Assert.assertEquals("true", mapper.mapCell(cellData));
+ }
+
+ @Test
+ public void mapCell_falseValue_returnFalseString() throws IOException, SQLException {
+ DataTypeFactory factory = new CommonDataTypeFactory("boolean");
+ DataType dataType = factory.generate();
+ PGBooleanMapper mapper = new PGBooleanMapper();
+ PGTestCellData cellData = new PGTestCellData(dataType);
+ cellData.setObjectValue(Boolean.FALSE);
+ Assert.assertEquals("false", mapper.mapCell(cellData));
+ }
+
+ @Test
+ public void mapCell_nullValue_returnNull() throws IOException, SQLException {
+ DataTypeFactory factory = new CommonDataTypeFactory("boolean");
+ DataType dataType = factory.generate();
+ PGBooleanMapper mapper = new PGBooleanMapper();
+ PGTestCellData cellData = new PGTestCellData(dataType);
+ cellData.setObjectValue(null);
+ Assert.assertNull(mapper.mapCell(cellData));
+ }
+
+ @Test
+ public void supports_boolean_supports() throws IOException, SQLException {
+ PGBooleanMapper mapper = new PGBooleanMapper();
+ DataTypeFactory factory = new CommonDataTypeFactory("boolean");
+ Assert.assertTrue(mapper.supports(factory.generate()));
+ }
+
+ @Test
+ public void supports_bool_supports() throws IOException, SQLException {
+ PGBooleanMapper mapper = new PGBooleanMapper();
+ DataTypeFactory factory = new CommonDataTypeFactory("bool");
+ Assert.assertTrue(mapper.supports(factory.generate()));
+ }
+
+ @Test
+ public void supports_varchar_notSupports() throws IOException, SQLException {
+ PGBooleanMapper mapper = new PGBooleanMapper();
+ DataTypeFactory factory = new CommonDataTypeFactory("varchar");
+ Assert.assertFalse(mapper.supports(factory.generate()));
+ }
+
+}
diff --git a/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/PGByteaMapperTest.java b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/PGByteaMapperTest.java
new file mode 100644
index 0000000000..eb2dd7cab3
--- /dev/null
+++ b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/PGByteaMapperTest.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.core.sql.execute.mapper;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.sql.SQLException;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import com.oceanbase.odc.core.sql.execute.tool.PGTestCellData;
+import com.oceanbase.tools.dbbrowser.model.datatype.CommonDataTypeFactory;
+import com.oceanbase.tools.dbbrowser.model.datatype.DataType;
+import com.oceanbase.tools.dbbrowser.model.datatype.DataTypeFactory;
+
+/**
+ * Test cases for {@link PGByteaMapper}
+ *
+ * @author ODC Team
+ * @date 2026-03-12
+ */
+public class PGByteaMapperTest {
+
+ @Test
+ public void mapCell_smallBinary_returnBytes() throws IOException, SQLException {
+ DataTypeFactory factory = new CommonDataTypeFactory("bytea");
+ DataType dataType = factory.generate();
+ PGByteaMapper mapper = new PGByteaMapper();
+ PGTestCellData cellData = new PGTestCellData(dataType);
+ byte[] bytes = new byte[] {0x01, 0x02, 0x03, 0x04, 0x05};
+ cellData.setBinaryStreamValue(new ByteArrayInputStream(bytes));
+ Assert.assertEquals("(bytea) 5 B", mapper.mapCell(cellData));
+ }
+
+ @Test
+ public void mapCell_largeBinaryKb_returnKb() throws IOException, SQLException {
+ DataTypeFactory factory = new CommonDataTypeFactory("bytea");
+ DataType dataType = factory.generate();
+ PGByteaMapper mapper = new PGByteaMapper();
+ PGTestCellData cellData = new PGTestCellData(dataType);
+ // 2048 bytes = 2 KB
+ byte[] bytes = new byte[2048];
+ cellData.setBinaryStreamValue(new ByteArrayInputStream(bytes));
+ Assert.assertEquals("(bytea) 2 KB", mapper.mapCell(cellData));
+ }
+
+ @Test
+ public void mapCell_nullInput_returnNull() throws IOException, SQLException {
+ DataTypeFactory factory = new CommonDataTypeFactory("bytea");
+ DataType dataType = factory.generate();
+ PGByteaMapper mapper = new PGByteaMapper();
+ PGTestCellData cellData = new PGTestCellData(dataType);
+ cellData.setBinaryStreamValue(null);
+ Assert.assertNull(mapper.mapCell(cellData));
+ }
+
+ @Test
+ public void supports_bytea_supports() throws IOException, SQLException {
+ PGByteaMapper mapper = new PGByteaMapper();
+ DataTypeFactory factory = new CommonDataTypeFactory("bytea");
+ Assert.assertTrue(mapper.supports(factory.generate()));
+ }
+
+ @Test
+ public void supports_blob_notSupports() throws IOException, SQLException {
+ PGByteaMapper mapper = new PGByteaMapper();
+ DataTypeFactory factory = new CommonDataTypeFactory("blob");
+ Assert.assertFalse(mapper.supports(factory.generate()));
+ }
+
+}
diff --git a/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/PGNumericMapperTest.java b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/PGNumericMapperTest.java
new file mode 100644
index 0000000000..544c4891f8
--- /dev/null
+++ b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/PGNumericMapperTest.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.core.sql.execute.mapper;
+
+import java.io.IOException;
+import java.math.BigDecimal;
+import java.sql.SQLException;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import com.oceanbase.odc.core.sql.execute.tool.PGTestCellData;
+import com.oceanbase.tools.dbbrowser.model.datatype.CommonDataTypeFactory;
+import com.oceanbase.tools.dbbrowser.model.datatype.DataType;
+import com.oceanbase.tools.dbbrowser.model.datatype.DataTypeFactory;
+
+/**
+ * Test cases for {@link PGNumericMapper}
+ *
+ * @author ODC Team
+ * @date 2026-03-12
+ */
+public class PGNumericMapperTest {
+
+ @Test
+ public void mapCell_normalValue_returnPlainString() throws IOException, SQLException {
+ DataTypeFactory factory = new CommonDataTypeFactory("numeric");
+ DataType dataType = factory.generate();
+ PGNumericMapper mapper = new PGNumericMapper();
+ PGTestCellData cellData = new PGTestCellData(dataType);
+ cellData.setBigDecimalValue(new BigDecimal("123.456"));
+ Assert.assertEquals("123.456", mapper.mapCell(cellData));
+ }
+
+ @Test
+ public void mapCell_highPrecisionValue_preservePrecision() throws IOException, SQLException {
+ DataTypeFactory factory = new CommonDataTypeFactory("numeric");
+ DataType dataType = factory.generate();
+ PGNumericMapper mapper = new PGNumericMapper();
+ PGTestCellData cellData = new PGTestCellData(dataType);
+ cellData.setBigDecimalValue(new BigDecimal("123456789.12345678901234567890"));
+ Assert.assertEquals("123456789.12345678901234567890", mapper.mapCell(cellData));
+ }
+
+ @Test
+ public void mapCell_nullValue_returnNull() throws IOException, SQLException {
+ DataTypeFactory factory = new CommonDataTypeFactory("numeric");
+ DataType dataType = factory.generate();
+ PGNumericMapper mapper = new PGNumericMapper();
+ PGTestCellData cellData = new PGTestCellData(dataType);
+ cellData.setBigDecimalValue(null);
+ Assert.assertNull(mapper.mapCell(cellData));
+ }
+
+ @Test
+ public void supports_numeric_supports() throws IOException, SQLException {
+ PGNumericMapper mapper = new PGNumericMapper();
+ DataTypeFactory factory = new CommonDataTypeFactory("numeric");
+ Assert.assertTrue(mapper.supports(factory.generate()));
+ }
+
+ @Test
+ public void supports_decimal_supports() throws IOException, SQLException {
+ PGNumericMapper mapper = new PGNumericMapper();
+ DataTypeFactory factory = new CommonDataTypeFactory("decimal");
+ Assert.assertTrue(mapper.supports(factory.generate()));
+ }
+
+ @Test
+ public void supports_integer_notSupports() throws IOException, SQLException {
+ PGNumericMapper mapper = new PGNumericMapper();
+ DataTypeFactory factory = new CommonDataTypeFactory("integer");
+ Assert.assertFalse(mapper.supports(factory.generate()));
+ }
+
+}
diff --git a/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/PGTimestampTZMapperTest.java b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/PGTimestampTZMapperTest.java
new file mode 100644
index 0000000000..92c8fe3d28
--- /dev/null
+++ b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/mapper/PGTimestampTZMapperTest.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.core.sql.execute.mapper;
+
+import java.io.IOException;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import com.oceanbase.odc.core.sql.execute.tool.PGTestCellData;
+import com.oceanbase.tools.dbbrowser.model.datatype.CommonDataTypeFactory;
+import com.oceanbase.tools.dbbrowser.model.datatype.DataType;
+import com.oceanbase.tools.dbbrowser.model.datatype.DataTypeFactory;
+
+/**
+ * Test cases for {@link PGTimestampTZMapper}
+ *
+ * @author ODC Team
+ * @date 2026-03-12
+ */
+public class PGTimestampTZMapperTest {
+
+ @Test
+ public void mapCell_normalTimestamp_returnString() throws IOException, SQLException {
+ DataTypeFactory factory = new CommonDataTypeFactory("timestamptz");
+ DataType dataType = factory.generate();
+ PGTimestampTZMapper mapper = new PGTimestampTZMapper();
+ PGTestCellData cellData = new PGTestCellData(dataType);
+ Timestamp timestamp = Timestamp.valueOf("2026-03-12 10:30:45.123456");
+ cellData.setTimestampValue(timestamp);
+ Assert.assertNotNull(mapper.mapCell(cellData));
+ }
+
+ @Test
+ public void mapCell_nullTimestamp_returnNull() throws IOException, SQLException {
+ DataTypeFactory factory = new CommonDataTypeFactory("timestamptz");
+ DataType dataType = factory.generate();
+ PGTimestampTZMapper mapper = new PGTimestampTZMapper();
+ PGTestCellData cellData = new PGTestCellData(dataType);
+ cellData.setTimestampValue(null);
+ Assert.assertNull(mapper.mapCell(cellData));
+ }
+
+ @Test
+ public void supports_timestamptz_supports() throws IOException, SQLException {
+ PGTimestampTZMapper mapper = new PGTimestampTZMapper();
+ DataTypeFactory factory = new CommonDataTypeFactory("timestamptz");
+ Assert.assertTrue(mapper.supports(factory.generate()));
+ }
+
+ @Test
+ public void supports_timestampWithTimeZone_supports() throws IOException, SQLException {
+ PGTimestampTZMapper mapper = new PGTimestampTZMapper();
+ CommonDataTypeFactory factory = new CommonDataTypeFactory("timestamp with time zone");
+ Assert.assertTrue(mapper.supports(factory.generate()));
+ }
+
+ @Test
+ public void supports_timestamp_notSupports() throws IOException, SQLException {
+ PGTimestampTZMapper mapper = new PGTimestampTZMapper();
+ DataTypeFactory factory = new CommonDataTypeFactory("timestamp");
+ Assert.assertFalse(mapper.supports(factory.generate()));
+ }
+
+}
diff --git a/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/tool/PGTestCellData.java b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/tool/PGTestCellData.java
new file mode 100644
index 0000000000..1c3802a2ee
--- /dev/null
+++ b/server/odc-core/src/test/java/com/oceanbase/odc/core/sql/execute/tool/PGTestCellData.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.core.sql.execute.tool;
+
+import java.math.BigDecimal;
+import java.sql.Array;
+import java.sql.Timestamp;
+
+import com.oceanbase.odc.core.sql.execute.mapper.CellData;
+import com.oceanbase.tools.dbbrowser.model.datatype.DataType;
+
+import lombok.NonNull;
+
+/**
+ * Test CellData for PostgreSQL mappers that can set various values via reflection.
+ *
+ * @author ODC Team
+ * @date 2026-03-12
+ */
+public class PGTestCellData extends CellData {
+
+ private Object objectValue;
+ private BigDecimal bigDecimalValue;
+ private byte[] bytesValue;
+ private Array arrayValue;
+ private Timestamp timestampValue;
+ private java.io.InputStream binaryStreamValue;
+
+ public PGTestCellData(@NonNull DataType dataType) {
+ super(new org.h2.tools.SimpleResultSet(), 1, dataType);
+ }
+
+ public void setObjectValue(Object value) {
+ this.objectValue = value;
+ }
+
+ public void setBigDecimalValue(BigDecimal value) {
+ this.bigDecimalValue = value;
+ }
+
+ public void setBytesValue(byte[] value) {
+ this.bytesValue = value;
+ }
+
+ public void setArrayValue(Array value) {
+ this.arrayValue = value;
+ }
+
+ public void setTimestampValue(Timestamp value) {
+ this.timestampValue = value;
+ }
+
+ public void setBinaryStreamValue(java.io.InputStream value) {
+ this.binaryStreamValue = value;
+ }
+
+ @Override
+ public Object getObject() {
+ return objectValue;
+ }
+
+ @Override
+ public BigDecimal getBigDecimal() {
+ return bigDecimalValue;
+ }
+
+ @Override
+ public byte[] getBytes() {
+ return bytesValue;
+ }
+
+ @Override
+ public Array getArray() {
+ return arrayValue;
+ }
+
+ @Override
+ public Timestamp getTimestamp() {
+ return timestampValue;
+ }
+
+ @Override
+ public java.io.InputStream getBinaryStream() {
+ return binaryStreamValue;
+ }
+
+}
From 53eb50f0f40d1e47196a33ec68dba83a9f9fca5b Mon Sep 17 00:00:00 2001
From: sjjian <921465802@qq.com>
Date: Thu, 12 Mar 2026 04:54:38 +0000
Subject: [PATCH 17/25] feat(postgres): add pg-snippet.yml with 9 PostgreSQL
SQL code snippets
---
.../resources/builtin-snippet/pg-snippet.yml | 116 ++++++++++++++++++
1 file changed, 116 insertions(+)
create mode 100644 server/odc-service/src/main/resources/builtin-snippet/pg-snippet.yml
diff --git a/server/odc-service/src/main/resources/builtin-snippet/pg-snippet.yml b/server/odc-service/src/main/resources/builtin-snippet/pg-snippet.yml
new file mode 100644
index 0000000000..5bb271dfdf
--- /dev/null
+++ b/server/odc-service/src/main/resources/builtin-snippet/pg-snippet.yml
@@ -0,0 +1,116 @@
+# built-in snippet for PostgreSQL
+- name: pg create table
+ dialect_type: POSTGRESQL
+ tags: [ 'developer', 'postgresql', 'table', 'create' ]
+ type: DDL
+ description: 'Create a new table for PostgreSQL'
+ prefix: create_table
+ body: |
+ -- please modify column_name and data_type
+ CREATE TABLE IF NOT EXISTS "${1:schema}"."${2:table_name}" (
+ id BIGSERIAL PRIMARY KEY,
+ ${3:column_name} ${4:data_type} NOT NULL,
+ created_at TIMESTAMP DEFAULT now()
+ );
+ COMMENT ON TABLE "${1:schema}"."${2:table_name}" IS '${5:table_description}';
+
+- name: pg create index
+ dialect_type: POSTGRESQL
+ tags: [ 'developer', 'postgresql', 'index', 'create' ]
+ type: DDL
+ description: 'Create a new index for PostgreSQL'
+ prefix: create_index
+ body: |
+ CREATE ${1:UNIQUE }INDEX "${2:index_name}"
+ ON "${3:schema}"."${4:table_name}" USING ${5:btree} (${6:column_name});
+
+- name: pg create function
+ dialect_type: POSTGRESQL
+ tags: [ 'developer', 'postgresql', 'function', 'create' ]
+ type: DDL
+ description: 'Create a new function for PostgreSQL'
+ prefix: create_function
+ body: |
+ CREATE OR REPLACE FUNCTION "${1:schema}"."${2:function_name}" (
+ ${3:p_param1} ${4:INTEGER}
+ )
+ RETURNS ${5:INTEGER}
+ LANGUAGE plpgsql
+ AS \$\$
+ BEGIN
+ RETURN ${3:p_param1};
+ END;
+ \$\$;
+
+- name: pg create procedure
+ dialect_type: POSTGRESQL
+ tags: [ 'developer', 'postgresql', 'procedure', 'create' ]
+ type: DDL
+ description: 'Create a new procedure for PostgreSQL (PG 11+)'
+ prefix: create_procedure
+ body: |
+ CREATE OR REPLACE PROCEDURE "${1:schema}"."${2:procedure_name}" (
+ ${3:p_param1} ${4:INTEGER}
+ )
+ LANGUAGE plpgsql
+ AS \$\$
+ BEGIN
+ -- procedure body
+ NULL;
+ END;
+ \$\$;
+
+- name: pg explain analyze
+ dialect_type: POSTGRESQL
+ tags: [ 'developer', 'postgresql', 'explain', 'performance' ]
+ type: DML
+ description: 'Explain analyze a query for PostgreSQL'
+ prefix: explain_analyze
+ body: |
+ EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
+ ${1:SELECT * FROM "schema"."table_name"};
+
+- name: pg query table info
+ dialect_type: POSTGRESQL
+ tags: [ 'developer', 'postgresql', 'table', 'info' ]
+ type: DQL
+ description: 'Query table column information for PostgreSQL'
+ prefix: query_table_info
+ body: |
+ SELECT column_name, data_type, is_nullable, column_default
+ FROM information_schema.columns
+ WHERE table_schema = '${1:public}' AND table_name = '${2:table_name}'
+ ORDER BY ordinal_position;
+
+- name: pg upsert
+ dialect_type: POSTGRESQL
+ tags: [ 'developer', 'postgresql', 'upsert', 'conflict' ]
+ type: DML
+ description: 'Insert on conflict (upsert) for PostgreSQL'
+ prefix: upsert
+ body: |
+ INSERT INTO "${1:schema}"."${2:table_name}" (${3:col1}, ${4:col2})
+ VALUES (${5:val1}, ${6:val2})
+ ON CONFLICT (${3:col1})
+ DO UPDATE SET ${4:col2} = EXCLUDED.${4:col2};
+
+- name: pg query user list
+ dialect_type: POSTGRESQL
+ tags: [ 'developer', 'postgresql', 'user', 'role' ]
+ type: DQL
+ description: 'Query all database roles for PostgreSQL'
+ prefix: query_user_list
+ body: |
+ SELECT rolname, rolsuper, rolcreatedb, rolcreaterole, rolcanlogin
+ FROM pg_roles
+ ORDER BY rolname;
+
+- name: pg grant privileges
+ dialect_type: POSTGRESQL
+ tags: [ 'developer', 'postgresql', 'grant', 'privilege' ]
+ type: DDL
+ description: 'Grant privileges on schema or table for PostgreSQL'
+ prefix: grant_privileges
+ body: |
+ GRANT ${1:ALL PRIVILEGES} ON ${2:ALL TABLES IN SCHEMA} "${3:public}"
+ TO "${4:role_name}";
From 5f545c52dba8e191105c4da4caf63a94c877c9b4 Mon Sep 17 00:00:00 2001
From: sjjian <921465802@qq.com>
Date: Fri, 13 Mar 2026 03:28:27 +0000
Subject: [PATCH 18/25] fix same bug
---
.../odc/common/util/JdbcOperationsUtil.java | 26 ++++++-------------
.../odc/config/jpa/EnhancedJpaRepository.java | 20 ++++++--------
server/plugins/schema-plugin-postgres/pom.xml | 2 +-
3 files changed, 17 insertions(+), 31 deletions(-)
diff --git a/server/odc-common/src/main/java/com/oceanbase/odc/common/util/JdbcOperationsUtil.java b/server/odc-common/src/main/java/com/oceanbase/odc/common/util/JdbcOperationsUtil.java
index 2333f40ccb..4fbcf6b7aa 100644
--- a/server/odc-common/src/main/java/com/oceanbase/odc/common/util/JdbcOperationsUtil.java
+++ b/server/odc-common/src/main/java/com/oceanbase/odc/common/util/JdbcOperationsUtil.java
@@ -101,26 +101,16 @@ public static List batchCreate(JdbcOperations jdbcOperations, List ent
/**
* 从 ResultSet 中获取生成的主键 ID
*
- * 问题描述:
- * - MySQL 批量插入时,getGeneratedKeys() 返回的 ResultSet 可能没有列名,
- * 导致通过列名访问(如 getObject("id"))抛出 SQLException: Column 'id' not found
- * - 不同数据库驱动对 getGeneratedKeys() 返回的 ResultSet 列名处理不一致:
- * - MySQL: 批量插入时可能无列名,或列名为 "GENERATED_KEY"
- * - Oracle: 可能有实际列名或 "GENERATED_KEY"
- * - SQL Server: 列名可能为 "GENERATED_KEYS" 或实际列名
- * - OceanBase for MySQL: 兼容 MySQL 协议,行为与 MySQL 相同
+ * 问题描述: - MySQL 批量插入时,getGeneratedKeys() 返回的 ResultSet 可能没有列名, 导致通过列名访问(如 getObject("id"))抛出
+ * SQLException: Column 'id' not found - 不同数据库驱动对 getGeneratedKeys() 返回的 ResultSet 列名处理不一致: - MySQL:
+ * 批量插入时可能无列名,或列名为 "GENERATED_KEY" - Oracle: 可能有实际列名或 "GENERATED_KEY" - SQL Server: 列名可能为
+ * "GENERATED_KEYS" 或实际列名 - OceanBase for MySQL: 兼容 MySQL 协议,行为与 MySQL 相同
*
- * 解决方案:
- * - 优先通过索引访问(resultSet.getObject(1)):
- * JDBC 规范强制要求 getGeneratedKeys() 返回的 ResultSet 第一列就是生成的主键,
- * 这是标准做法,不依赖列名,适用于所有数据库
- * - 回退到列名访问:如果索引访问失败(理论上不应该),
- * 尝试通过常见列名访问,兼容不同驱动的列名差异
+ * 解决方案: - 优先通过索引访问(resultSet.getObject(1)): JDBC 规范强制要求 getGeneratedKeys() 返回的 ResultSet
+ * 第一列就是生成的主键, 这是标准做法,不依赖列名,适用于所有数据库 - 回退到列名访问:如果索引访问失败(理论上不应该), 尝试通过常见列名访问,兼容不同驱动的列名差异
*
- * 兼容性保证:
- * - 索引访问(第 1 列):100% 兼容所有数据库,符合 JDBC 规范
- * - 列名回退机制:处理特殊情况,提供额外容错保障
- * - 异常容错:多层 try-catch 确保不会因列名问题导致程序崩溃
+ * 兼容性保证: - 索引访问(第 1 列):100% 兼容所有数据库,符合 JDBC 规范 - 列名回退机制:处理特殊情况,提供额外容错保障 - 异常容错:多层 try-catch
+ * 确保不会因列名问题导致程序崩溃
*
* @param resultSet getGeneratedKeys() 返回的 ResultSet,已调用 next() 定位到当前行
* @return 生成的主键 ID,如果无法获取则返回 null
diff --git a/server/odc-service/src/main/java/com/oceanbase/odc/config/jpa/EnhancedJpaRepository.java b/server/odc-service/src/main/java/com/oceanbase/odc/config/jpa/EnhancedJpaRepository.java
index b0f16dbecc..0ea8785b38 100644
--- a/server/odc-service/src/main/java/com/oceanbase/odc/config/jpa/EnhancedJpaRepository.java
+++ b/server/odc-service/src/main/java/com/oceanbase/odc/config/jpa/EnhancedJpaRepository.java
@@ -148,21 +148,17 @@ private DataSource getDataSource(EntityManager entityManager) {
*
* 问题描述:
*
- * - MySQL批量插入时,getGeneratedKeys()返回的ResultSet可能没有列名,导致通过列名访问(如getObject("id"))抛出SQLException: Column 'id' not found
- * - 不同数据库驱动对getGeneratedKeys()返回的ResultSet列名处理不一致:
- * - MySQL: 批量插入时可能无列名,或列名为"GENERATED_KEY"
- * - Oracle: 可能有实际列名或"GENERATED_KEY"
- * - SQL Server: 列名可能为"GENERATED_KEYS"或实际列名
- * - OceanBase for MySQL: 兼容MySQL协议,行为与MySQL相同
+ * - MySQL批量插入时,getGeneratedKeys()返回的ResultSet可能没有列名,导致通过列名访问(如getObject("id"))抛出SQLException:
+ * Column 'id' not found - 不同数据库驱动对getGeneratedKeys()返回的ResultSet列名处理不一致: - MySQL:
+ * 批量插入时可能无列名,或列名为"GENERATED_KEY" - Oracle: 可能有实际列名或"GENERATED_KEY" - SQL Server:
+ * 列名可能为"GENERATED_KEYS"或实际列名 - OceanBase for MySQL: 兼容MySQL协议,行为与MySQL相同
*
- * 解决方案:
- * - 优先通过索引访问(resultSet.getObject(1)):JDBC规范强制要求getGeneratedKeys()返回的ResultSet第一列就是生成的主键,这是标准做法,不依赖列名,适用于所有数据库
+ * 解决方案: -
+ * 优先通过索引访问(resultSet.getObject(1)):JDBC规范强制要求getGeneratedKeys()返回的ResultSet第一列就是生成的主键,这是标准做法,不依赖列名,适用于所有数据库
* - 回退到列名访问:如果索引访问失败(理论上不应该),尝试通过常见列名访问,兼容不同驱动的列名差异
*
- * 兼容性保证:
- * - 索引访问(第1列):100%兼容所有数据库,符合JDBC规范
- * - 列名回退机制:处理特殊情况,提供额外容错保障
- * - 异常容错:多层try-catch确保不会因列名问题导致程序崩溃
+ * 兼容性保证: - 索引访问(第1列):100%兼容所有数据库,符合JDBC规范 - 列名回退机制:处理特殊情况,提供额外容错保障 -
+ * 异常容错:多层try-catch确保不会因列名问题导致程序崩溃
*
* @param resultSet getGeneratedKeys()返回的ResultSet,已调用next()定位到当前行
* @return 生成的主键ID,如果无法获取则返回null
diff --git a/server/plugins/schema-plugin-postgres/pom.xml b/server/plugins/schema-plugin-postgres/pom.xml
index 2758e229b3..b3fe511bc9 100644
--- a/server/plugins/schema-plugin-postgres/pom.xml
+++ b/server/plugins/schema-plugin-postgres/pom.xml
@@ -32,7 +32,7 @@
${project.parent.parent.basedir}com.oceanbase.odc.plugin.schema.postgres.PostgresSchemaPlugin
- schema-plugin-ob-mysql
+ schema-plugin-ob-mysql,connect-plugin-postgres
From dd181537a06d9c550ee34846640a600440ab20c0 Mon Sep 17 00:00:00 2001
From: Sun Jian <921465802@qq.com>
Date: Mon, 16 Mar 2026 10:53:19 +0800
Subject: [PATCH 19/25] =?UTF-8?q?fix=20=E8=A7=86=E5=9B=BE=E5=AD=98?=
=?UTF-8?q?=E5=82=A8=E8=BF=87=E7=A8=8B=E8=B5=84=E6=BA=90=E6=97=A0=E6=B3=95?=
=?UTF-8?q?=E5=B1=95=E7=A4=BA=E7=9A=84=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...11__add_postgresql_version_diff_config.sql | 146 +++++++
.../PostgreSQLVersionDiffConfigTest.java | 356 ++++++++++++++++++
2 files changed, 502 insertions(+)
create mode 100644 server/odc-migrate/src/main/resources/migrate/common/V_4_3_4_11__add_postgresql_version_diff_config.sql
create mode 100644 server/odc-service/src/test/java/com/oceanbase/odc/service/feature/PostgreSQLVersionDiffConfigTest.java
diff --git a/server/odc-migrate/src/main/resources/migrate/common/V_4_3_4_11__add_postgresql_version_diff_config.sql b/server/odc-migrate/src/main/resources/migrate/common/V_4_3_4_11__add_postgresql_version_diff_config.sql
new file mode 100644
index 0000000000..f684c01940
--- /dev/null
+++ b/server/odc-migrate/src/main/resources/migrate/common/V_4_3_4_11__add_postgresql_version_diff_config.sql
@@ -0,0 +1,146 @@
+-- Add PostgreSQL version diff config for supporting view/function/procedure and other features
+-- This fixes the issue where PostgreSQL data source doesn't show view/function/procedure groups in resource tree
+
+-- Support view/function/procedure for PostgreSQL
+-- PostgreSQL has supported views since early versions, functions since early versions
+-- CREATE PROCEDURE is supported since PostgreSQL 11
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_view','POSTGRESQL','true','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_function','POSTGRESQL','true','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_procedure','POSTGRESQL','true','11',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+-- Column data types for PostgreSQL
+-- Reference: https://www.postgresql.org/docs/current/datatype.html
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('column_data_type', 'POSTGRESQL',
+'smallint:NUMERIC, integer:NUMERIC, bigint:NUMERIC, decimal:NUMERIC, numeric:NUMERIC, real:NUMERIC, double precision:NUMERIC, serial:NUMERIC, bigserial:NUMERIC, smallserial:NUMERIC, money:NUMERIC,
+char:TEXT, varchar:TEXT, text:OBJECT, bytea:OBJECT,
+timestamp:TIMESTAMP, timestamptz:TIMESTAMP, date:DATE, time:TIME, timetz:TIME, interval:OBJECT,
+boolean:OBJECT, bool:OBJECT,
+json:OBJECT, jsonb:OBJECT,
+uuid:OBJECT, xml:OBJECT,
+inet:OBJECT, cidr:OBJECT, macaddr:OBJECT, macaddr8:OBJECT,
+point:OBJECT, line:OBJECT, lseg:OBJECT, box:OBJECT, path:OBJECT, polygon:OBJECT, circle:OBJECT',
+'0', CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+-- Support other PostgreSQL features
+-- PostgreSQL supports constraints, partitions (declarative partitioning since PG 10), foreign keys
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_constraint','POSTGRESQL','true','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_constraint_modify','POSTGRESQL','true','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_partition','POSTGRESQL','true','10',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_partition_modify','POSTGRESQL','true','10',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_show_foreign_key','POSTGRESQL','true','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+-- PostgreSQL supports kill session/query via pg_cancel_backend/pg_terminate_backend
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_kill_session','POSTGRESQL','true','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_kill_query','POSTGRESQL','true','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+-- PostgreSQL supports EXPLAIN for execution plans
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_sql_explain','POSTGRESQL','true','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+-- Features NOT supported by PostgreSQL (set to false)
+-- PostgreSQL doesn't have built-in recycle bin
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_recycle_bin','POSTGRESQL','false','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+-- PostgreSQL doesn't have auto-increment like MySQL, it uses SERIAL/IDENTITY
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_partition_plan','POSTGRESQL','false','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+-- PostgreSQL doesn't have packages like Oracle
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_package','POSTGRESQL','false','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+-- PostgreSQL doesn't have rowid like Oracle
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_rowid','POSTGRESQL','false','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+-- PostgreSQL supports sequences
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_sequence','POSTGRESQL','true','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+-- PostgreSQL supports triggers
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_trigger','POSTGRESQL','true','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_trigger_ddl','POSTGRESQL','true','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+-- PostgreSQL supports custom types
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_type','POSTGRESQL','true','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+-- PostgreSQL doesn't have synonyms like Oracle
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_synonym','POSTGRESQL','false','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+-- Mock data support - needs evaluation with PostgreSQL data types
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_mock_data','POSTGRESQL','false','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+-- Shadow table - not supported for PostgreSQL
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_shadowtable','POSTGRESQL','false','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+-- PL/SQL debug - not supported for PostgreSQL
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_pl_debug','POSTGRESQL','false','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+-- SQL trace - PostgreSQL uses EXPLAIN ANALYZE instead
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_sql_trace','POSTGRESQL','false','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+-- Data export/import
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_data_export','POSTGRESQL','false','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_db_import','POSTGRESQL','false','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
+
+insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
+values('support_db_export','POSTGRESQL','false','0',CURRENT_TIMESTAMP)
+ON DUPLICATE KEY update `config_key`=`config_key`;
diff --git a/server/odc-service/src/test/java/com/oceanbase/odc/service/feature/PostgreSQLVersionDiffConfigTest.java b/server/odc-service/src/test/java/com/oceanbase/odc/service/feature/PostgreSQLVersionDiffConfigTest.java
new file mode 100644
index 0000000000..deea82f88c
--- /dev/null
+++ b/server/odc-service/src/test/java/com/oceanbase/odc/service/feature/PostgreSQLVersionDiffConfigTest.java
@@ -0,0 +1,356 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.service.feature;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Unit tests for PostgreSQL Version Diff Config
+ *
+ * This test verifies that the PostgreSQL configuration is properly added
+ * to the odc_version_diff_config table via migration script.
+ *
+ * Related issue: E-001 - 视图/函数/存储过程分组不可见
+ * Fix: Add PostgreSQL support_view, support_function, support_procedure configs
+ *
+ * 设计文档参考:
+ * - 需求文档 AC-005.4: 对象类型分组展示
+ * - 需求文档 AC-005.8: 后端 supportFeature 标志正确反映 PostgreSQL 支持的对象类型
+ * - 任务7描述: VersionDiffConfigService.getSupportFeatures() 从 odc_version_diff_config 表读取配置
+ */
+public class PostgreSQLVersionDiffConfigTest {
+
+ // The migration script file path relative to odc module directory
+ private static final String MIGRATION_SCRIPT_RELATIVE_PATH =
+ "server/odc-migrate/src/main/resources/migrate/common/V_4_3_4_11__add_postgresql_version_diff_config.sql";
+
+ /**
+ * PostgreSQL config keys that must be present for resource tree to work correctly.
+ * These are the minimum required configs to fix E-001:
+ * - support_view: enables view group in resource tree
+ * - support_function: enables function group in resource tree
+ * - support_procedure: enables procedure group in resource tree
+ */
+ private static final String[] REQUIRED_POSTGRESQL_CONFIGS = {
+ "support_view",
+ "support_function",
+ "support_procedure",
+ "column_data_type",
+ "support_constraint",
+ "support_constraint_modify",
+ "support_partition",
+ "support_show_foreign_key",
+ "support_kill_session",
+ "support_kill_query",
+ "support_sql_explain",
+ "support_sequence",
+ "support_trigger",
+ "support_type"
+ };
+
+ /**
+ * Test case 1: Verify migration script file exists
+ *
+ * 测试目标:验证迁移脚本文件存在
+ */
+ @Test
+ public void testMigrationScript_fileExists() {
+ File scriptFile = getMigrationScriptFile();
+ Assert.assertTrue("Migration script file should exist: " + scriptFile.getAbsolutePath(),
+ scriptFile.exists());
+ }
+
+ /**
+ * Test case 2: Verify migration script contains required PostgreSQL support_view config
+ *
+ * 测试目标:验证迁移脚本包含 support_view 配置
+ * 需求引用:AC-005.4, AC-005.8, E-001 修复
+ */
+ @Test
+ public void testMigrationScript_containsSupportView() throws Exception {
+ String content = readMigrationScript();
+ Assert.assertTrue("Migration script should contain support_view for POSTGRESQL",
+ content.contains("'support_view','POSTGRESQL'"));
+ }
+
+ /**
+ * Test case 3: Verify migration script contains required PostgreSQL support_function config
+ *
+ * 测试目标:验证迁移脚本包含 support_function 配置
+ * 需求引用:AC-005.4, AC-005.8, E-001 修复
+ */
+ @Test
+ public void testMigrationScript_containsSupportFunction() throws Exception {
+ String content = readMigrationScript();
+ Assert.assertTrue("Migration script should contain support_function for POSTGRESQL",
+ content.contains("'support_function','POSTGRESQL'"));
+ }
+
+ /**
+ * Test case 4: Verify migration script contains required PostgreSQL support_procedure config
+ *
+ * 测试目标:验证迁移脚本包含 support_procedure 配置
+ * 需求引用:AC-005.4, AC-005.8, E-001 修复
+ */
+ @Test
+ public void testMigrationScript_containsSupportProcedure() throws Exception {
+ String content = readMigrationScript();
+ Assert.assertTrue("Migration script should contain support_procedure for POSTGRESQL",
+ content.contains("'support_procedure','POSTGRESQL'"));
+ }
+
+ /**
+ * Test case 5: Verify migration script contains all required PostgreSQL configs
+ *
+ * 测试目标:验证迁移脚本包含所有必需的 PostgreSQL 配置
+ */
+ @Test
+ public void testMigrationScript_containsAllRequiredConfigs() throws Exception {
+ String content = readMigrationScript();
+
+ // Extract all config keys for POSTGRESQL from the migration script
+ Set actualConfigKeys = extractPostgreSqlConfigKeys(content);
+
+ for (String requiredConfig : REQUIRED_POSTGRESQL_CONFIGS) {
+ Assert.assertTrue(
+ "Migration script should contain '" + requiredConfig + "' for POSTGRESQL. "
+ + "Found configs: " + actualConfigKeys,
+ actualConfigKeys.contains(requiredConfig.toLowerCase()));
+ }
+ }
+
+ /**
+ * Test case 6: Verify support_view is set to true for PostgreSQL
+ *
+ * 测试目标:验证 support_view 配置值为 true
+ */
+ @Test
+ public void testMigrationScript_supportView_isTrue() throws Exception {
+ String content = readMigrationScript();
+ // Pattern to match support_view config
+ Pattern pattern = Pattern.compile(
+ "values\\s*\\(\\s*'support_view'\\s*,\\s*'POSTGRESQL'\\s*,\\s*'([^']+)'",
+ Pattern.CASE_INSENSITIVE);
+ Matcher matcher = pattern.matcher(content);
+
+ Assert.assertTrue("Should find support_view config for POSTGRESQL", matcher.find());
+ Assert.assertEquals("support_view should be 'true' for POSTGRESQL",
+ "true", matcher.group(1).toLowerCase());
+ }
+
+ /**
+ * Test case 7: Verify support_function is set to true for PostgreSQL
+ *
+ * 测试目标:验证 support_function 配置值为 true
+ */
+ @Test
+ public void testMigrationScript_supportFunction_isTrue() throws Exception {
+ String content = readMigrationScript();
+ Pattern pattern = Pattern.compile(
+ "values\\s*\\(\\s*'support_function'\\s*,\\s*'POSTGRESQL'\\s*,\\s*'([^']+)'",
+ Pattern.CASE_INSENSITIVE);
+ Matcher matcher = pattern.matcher(content);
+
+ Assert.assertTrue("Should find support_function config for POSTGRESQL", matcher.find());
+ Assert.assertEquals("support_function should be 'true' for POSTGRESQL",
+ "true", matcher.group(1).toLowerCase());
+ }
+
+ /**
+ * Test case 8: Verify support_procedure is set to true for PostgreSQL
+ * Note: PostgreSQL 11+ supports CREATE PROCEDURE
+ *
+ * 测试目标:验证 support_procedure 配置值为 true
+ */
+ @Test
+ public void testMigrationScript_supportProcedure_isTrue() throws Exception {
+ String content = readMigrationScript();
+ Pattern pattern = Pattern.compile(
+ "values\\s*\\(\\s*'support_procedure'\\s*,\\s*'POSTGRESQL'\\s*,\\s*'([^']+)'",
+ Pattern.CASE_INSENSITIVE);
+ Matcher matcher = pattern.matcher(content);
+
+ Assert.assertTrue("Should find support_procedure config for POSTGRESQL", matcher.find());
+ Assert.assertEquals("support_procedure should be 'true' for POSTGRESQL (PG 11+)",
+ "true", matcher.group(1).toLowerCase());
+ }
+
+ /**
+ * Test case 9: Verify column_data_type config contains PostgreSQL specific types
+ *
+ * 测试目标:验证 column_data_type 配置包含 PostgreSQL 特有数据类型
+ */
+ @Test
+ public void testMigrationScript_columnDataType_containsPostgreSqlTypes() throws Exception {
+ String content = readMigrationScript();
+
+ // Check for PostgreSQL specific data types
+ String[] pgSpecificTypes = {
+ "jsonb", // PostgreSQL specific JSON type
+ "serial", "bigserial", // PostgreSQL auto-increment types
+ "uuid", // PostgreSQL UUID type
+ "timestamptz", "timetz", // PostgreSQL timezone-aware types
+ "inet", "cidr", "macaddr" // PostgreSQL network address types
+ };
+
+ for (String pgType : pgSpecificTypes) {
+ Assert.assertTrue(
+ "column_data_type config should contain PostgreSQL type: " + pgType,
+ content.toLowerCase().contains(pgType.toLowerCase()));
+ }
+ }
+
+ /**
+ * Test case 10: Verify db_mode value is POSTGRESQL (matches DialectType.POSTGRESQL.name())
+ *
+ * 测试目标:验证 db_mode 值为 POSTGRESQL
+ * 重要:VersionDiffConfigService.getDbMode() 返回 connectType.getDialectType().name()
+ * 即 "POSTGRESQL",迁移脚本必须使用相同的值
+ */
+ @Test
+ public void testMigrationScript_dbModeIsPostgreSql() throws Exception {
+ String content = readMigrationScript();
+
+ // Count occurrences of POSTGRESQL db_mode in migration script
+ Pattern pattern = Pattern.compile("'POSTGRESQL'", Pattern.CASE_INSENSITIVE);
+ Matcher matcher = pattern.matcher(content);
+ int count = 0;
+ while (matcher.find()) {
+ count++;
+ }
+
+ Assert.assertTrue(
+ "Migration script should contain 'POSTGRESQL' db_mode at least once",
+ count > 0);
+ }
+
+ /**
+ * Test case 11: Verify support_procedure min_version is '11' for PostgreSQL
+ * PostgreSQL 11 introduced CREATE PROCEDURE syntax
+ *
+ * 测试目标:验证 support_procedure 的 min_version 为 '11'
+ */
+ @Test
+ public void testMigrationScript_supportProcedure_minVersionIs11() throws Exception {
+ String content = readMigrationScript();
+ Pattern pattern = Pattern.compile(
+ "'support_procedure'\\s*,\\s*'POSTGRESQL'\\s*,\\s*'true'\\s*,\\s*'([^']+)'",
+ Pattern.CASE_INSENSITIVE);
+ Matcher matcher = pattern.matcher(content);
+
+ Assert.assertTrue("Should find support_procedure config for POSTGRESQL with min_version",
+ matcher.find());
+ Assert.assertEquals("support_procedure min_version should be '11' (PG 11 introduced CREATE PROCEDURE)",
+ "11", matcher.group(1));
+ }
+
+ /**
+ * Test case 12: Verify not supported features are set to false
+ * Features that PostgreSQL doesn't support natively should be false
+ *
+ * 测试目标:验证 PostgreSQL 不支持的特性被设置为 false
+ */
+ @Test
+ public void testMigrationScript_unsupportedFeatures_areFalse() throws Exception {
+ String content = readMigrationScript();
+
+ // Features that PostgreSQL doesn't support
+ String[] unsupportedFeatures = {
+ "support_recycle_bin", // PG doesn't have recycle bin like OceanBase
+ "support_package", // PG doesn't have packages like Oracle
+ "support_rowid", // PG doesn't have rowid like Oracle
+ "support_synonym", // PG doesn't have synonyms like Oracle
+ "support_shadowtable", // Not supported for PG
+ "support_pl_debug", // Not supported for PG
+ "support_sql_trace" // PG uses EXPLAIN ANALYZE instead
+ };
+
+ for (String feature : unsupportedFeatures) {
+ Assert.assertTrue(
+ feature + " should be set to 'false' for POSTGRESQL",
+ content.toLowerCase().contains(
+ ("'" + feature + "','POSTGRESQL','false'").toLowerCase()));
+ }
+ }
+
+ /**
+ * Test case 13: Verify the number of PostgreSQL configs is sufficient
+ *
+ * 测试目标:验证 PostgreSQL 配置数量充足(至少包含核心配置)
+ */
+ @Test
+ public void testMigrationScript_sufficientConfigCount() throws Exception {
+ String content = readMigrationScript();
+ Set configKeys = extractPostgreSqlConfigKeys(content);
+
+ // Should have at least 20 configs for a complete PostgreSQL support
+ Assert.assertTrue("Should have at least 20 PostgreSQL configs, but found: " + configKeys.size(),
+ configKeys.size() >= 20);
+ }
+
+ // Helper methods
+
+ private File getMigrationScriptFile() {
+ // Find the odc directory by looking for pom.xml
+ File currentDir = new File(System.getProperty("user.dir"));
+ while (currentDir != null && !new File(currentDir, "pom.xml").exists()) {
+ currentDir = currentDir.getParentFile();
+ }
+
+ // Navigate to the migration script
+ if (currentDir != null) {
+ return new File(currentDir, MIGRATION_SCRIPT_RELATIVE_PATH);
+ }
+
+ // Fallback: try relative path from current directory
+ return new File(MIGRATION_SCRIPT_RELATIVE_PATH);
+ }
+
+ private String readMigrationScript() throws Exception {
+ File scriptFile = getMigrationScriptFile();
+ try (InputStreamReader reader = new InputStreamReader(
+ new FileInputStream(scriptFile), StandardCharsets.UTF_8)) {
+ return new BufferedReader(reader).lines().collect(Collectors.joining("\n"));
+ }
+ }
+
+ private Set extractPostgreSqlConfigKeys(String content) {
+ Set configKeys = new HashSet<>();
+ // Pattern to match config_key for POSTGRESQL
+ // Example: values('support_view','POSTGRESQL',...
+ Pattern pattern = Pattern.compile(
+ "values\\s*\\(\\s*'([^']+)'\\s*,\\s*'POSTGRESQL'",
+ Pattern.CASE_INSENSITIVE);
+ Matcher matcher = pattern.matcher(content);
+
+ while (matcher.find()) {
+ configKeys.add(matcher.group(1).toLowerCase());
+ }
+
+ return configKeys;
+ }
+}
From 70a5874bc278ec133eb49749aeba511339fe56f9 Mon Sep 17 00:00:00 2001
From: Sun Jian <921465802@qq.com>
Date: Mon, 16 Mar 2026 11:18:23 +0800
Subject: [PATCH 20/25] =?UTF-8?q?fix=20=E8=A7=86=E5=9B=BE=E5=AD=98?=
=?UTF-8?q?=E5=82=A8=E8=BF=87=E7=A8=8B=E8=B5=84=E6=BA=90=E6=97=A0=E6=B3=95?=
=?UTF-8?q?=E5=B1=95=E7=A4=BA=E7=9A=84=E9=97=AE=E9=A2=98?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...g.sql => V_4_3_4_13__add_postgresql_version_diff_config.sql} | 0
.../odc/service/feature/PostgreSQLVersionDiffConfigTest.java | 2 +-
2 files changed, 1 insertion(+), 1 deletion(-)
rename server/odc-migrate/src/main/resources/migrate/common/{V_4_3_4_11__add_postgresql_version_diff_config.sql => V_4_3_4_13__add_postgresql_version_diff_config.sql} (100%)
diff --git a/server/odc-migrate/src/main/resources/migrate/common/V_4_3_4_11__add_postgresql_version_diff_config.sql b/server/odc-migrate/src/main/resources/migrate/common/V_4_3_4_13__add_postgresql_version_diff_config.sql
similarity index 100%
rename from server/odc-migrate/src/main/resources/migrate/common/V_4_3_4_11__add_postgresql_version_diff_config.sql
rename to server/odc-migrate/src/main/resources/migrate/common/V_4_3_4_13__add_postgresql_version_diff_config.sql
diff --git a/server/odc-service/src/test/java/com/oceanbase/odc/service/feature/PostgreSQLVersionDiffConfigTest.java b/server/odc-service/src/test/java/com/oceanbase/odc/service/feature/PostgreSQLVersionDiffConfigTest.java
index deea82f88c..155e111069 100644
--- a/server/odc-service/src/test/java/com/oceanbase/odc/service/feature/PostgreSQLVersionDiffConfigTest.java
+++ b/server/odc-service/src/test/java/com/oceanbase/odc/service/feature/PostgreSQLVersionDiffConfigTest.java
@@ -46,7 +46,7 @@ public class PostgreSQLVersionDiffConfigTest {
// The migration script file path relative to odc module directory
private static final String MIGRATION_SCRIPT_RELATIVE_PATH =
- "server/odc-migrate/src/main/resources/migrate/common/V_4_3_4_11__add_postgresql_version_diff_config.sql";
+ "server/odc-migrate/src/main/resources/migrate/common/V_4_3_4_13__add_postgresql_version_diff_config.sql";
/**
* PostgreSQL config keys that must be present for resource tree to work correctly.
From dc39921f61d2451e91921151cc5a61ad63c6e057 Mon Sep 17 00:00:00 2001
From: Sun Jian <921465802@qq.com>
Date: Wed, 18 Mar 2026 14:56:53 +0800
Subject: [PATCH 21/25] fix(postgres): disable trigger/sequence/type features
for PostgreSQL
Disable support_trigger, support_trigger_ddl, support_sequence, and
support_type configs for PostgreSQL because the corresponding ExtensionPoints
(TriggerExtensionPoint, SequenceExtensionPoint, TypeExtensionPoint) are not
implemented in schema-plugin-postgres module.
This fixes the error "Feature extension point is not supported for POSTGRESQL"
when users try to use trigger/sequence/type features in ODC.
Keep these features disabled until plugin extensions are implemented,
same as SQL Server which also doesn't implement these extension points.
---
...13__add_postgresql_version_diff_config.sql | 17 ++--
.../PostgreSQLVersionDiffConfigTest.java | 87 ++++++++++++++++++-
2 files changed, 93 insertions(+), 11 deletions(-)
diff --git a/server/odc-migrate/src/main/resources/migrate/common/V_4_3_4_13__add_postgresql_version_diff_config.sql b/server/odc-migrate/src/main/resources/migrate/common/V_4_3_4_13__add_postgresql_version_diff_config.sql
index f684c01940..1d1f7045fc 100644
--- a/server/odc-migrate/src/main/resources/migrate/common/V_4_3_4_13__add_postgresql_version_diff_config.sql
+++ b/server/odc-migrate/src/main/resources/migrate/common/V_4_3_4_13__add_postgresql_version_diff_config.sql
@@ -88,23 +88,26 @@ insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min
values('support_rowid','POSTGRESQL','false','0',CURRENT_TIMESTAMP)
ON DUPLICATE KEY update `config_key`=`config_key`;
--- PostgreSQL supports sequences
+-- PostgreSQL supports sequences, but ODC doesn't implement SequenceExtensionPoint yet
+-- Keep these features disabled until plugin extension is implemented (same as SQL Server)
insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
-values('support_sequence','POSTGRESQL','true','0',CURRENT_TIMESTAMP)
+values('support_sequence','POSTGRESQL','false','0',CURRENT_TIMESTAMP)
ON DUPLICATE KEY update `config_key`=`config_key`;
--- PostgreSQL supports triggers
+-- PostgreSQL supports triggers, but ODC doesn't implement TriggerExtensionPoint yet
+-- Keep these features disabled until plugin extension is implemented (same as SQL Server)
insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
-values('support_trigger','POSTGRESQL','true','0',CURRENT_TIMESTAMP)
+values('support_trigger','POSTGRESQL','false','0',CURRENT_TIMESTAMP)
ON DUPLICATE KEY update `config_key`=`config_key`;
insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
-values('support_trigger_ddl','POSTGRESQL','true','0',CURRENT_TIMESTAMP)
+values('support_trigger_ddl','POSTGRESQL','false','0',CURRENT_TIMESTAMP)
ON DUPLICATE KEY update `config_key`=`config_key`;
--- PostgreSQL supports custom types
+-- PostgreSQL supports custom types, but ODC doesn't implement TypeExtensionPoint yet
+-- Keep these features disabled until plugin extension is implemented (same as SQL Server)
insert into `odc_version_diff_config`(`config_key`,`db_mode`,`config_value`,`min_version`,`gmt_create`)
-values('support_type','POSTGRESQL','true','0',CURRENT_TIMESTAMP)
+values('support_type','POSTGRESQL','false','0',CURRENT_TIMESTAMP)
ON DUPLICATE KEY update `config_key`=`config_key`;
-- PostgreSQL doesn't have synonyms like Oracle
diff --git a/server/odc-service/src/test/java/com/oceanbase/odc/service/feature/PostgreSQLVersionDiffConfigTest.java b/server/odc-service/src/test/java/com/oceanbase/odc/service/feature/PostgreSQLVersionDiffConfigTest.java
index 155e111069..88de1d5fca 100644
--- a/server/odc-service/src/test/java/com/oceanbase/odc/service/feature/PostgreSQLVersionDiffConfigTest.java
+++ b/server/odc-service/src/test/java/com/oceanbase/odc/service/feature/PostgreSQLVersionDiffConfigTest.java
@@ -54,6 +54,10 @@ public class PostgreSQLVersionDiffConfigTest {
* - support_view: enables view group in resource tree
* - support_function: enables function group in resource tree
* - support_procedure: enables procedure group in resource tree
+ *
+ * Note: support_sequence, support_trigger, support_type are set to false because
+ * ODC doesn't implement the corresponding ExtensionPoints yet (same as SQL Server).
+ * They are not included in REQUIRED_POSTGRESQL_CONFIGS because they are disabled.
*/
private static final String[] REQUIRED_POSTGRESQL_CONFIGS = {
"support_view",
@@ -66,10 +70,7 @@ public class PostgreSQLVersionDiffConfigTest {
"support_show_foreign_key",
"support_kill_session",
"support_kill_query",
- "support_sql_explain",
- "support_sequence",
- "support_trigger",
- "support_type"
+ "support_sql_explain"
};
/**
@@ -297,6 +298,84 @@ public void testMigrationScript_unsupportedFeatures_areFalse() throws Exception
}
}
+ /**
+ * Test case 12.1: Verify support_trigger is set to false for PostgreSQL
+ * ODC doesn't implement TriggerExtensionPoint for PostgreSQL yet (same as SQL Server)
+ *
+ * 测试目标:验证 support_trigger 配置值为 false
+ * 原因:ODC 未实现 PostgresTriggerExtension,与 SQL Server 保持一致
+ */
+ @Test
+ public void testMigrationScript_supportTrigger_isFalse() throws Exception {
+ String content = readMigrationScript();
+ Pattern pattern = Pattern.compile(
+ "values\\s*\\(\\s*'support_trigger'\\s*,\\s*'POSTGRESQL'\\s*,\\s*'([^']+)'",
+ Pattern.CASE_INSENSITIVE);
+ Matcher matcher = pattern.matcher(content);
+
+ Assert.assertTrue("Should find support_trigger config for POSTGRESQL", matcher.find());
+ Assert.assertEquals("support_trigger should be 'false' for POSTGRESQL (not implemented)",
+ "false", matcher.group(1).toLowerCase());
+ }
+
+ /**
+ * Test case 12.2: Verify support_trigger_ddl is set to false for PostgreSQL
+ *
+ * 测试目标:验证 support_trigger_ddl 配置值为 false
+ */
+ @Test
+ public void testMigrationScript_supportTriggerDdl_isFalse() throws Exception {
+ String content = readMigrationScript();
+ Pattern pattern = Pattern.compile(
+ "values\\s*\\(\\s*'support_trigger_ddl'\\s*,\\s*'POSTGRESQL'\\s*,\\s*'([^']+)'",
+ Pattern.CASE_INSENSITIVE);
+ Matcher matcher = pattern.matcher(content);
+
+ Assert.assertTrue("Should find support_trigger_ddl config for POSTGRESQL", matcher.find());
+ Assert.assertEquals("support_trigger_ddl should be 'false' for POSTGRESQL (not implemented)",
+ "false", matcher.group(1).toLowerCase());
+ }
+
+ /**
+ * Test case 12.3: Verify support_sequence is set to false for PostgreSQL
+ * ODC doesn't implement SequenceExtensionPoint for PostgreSQL yet (same as SQL Server)
+ *
+ * 测试目标:验证 support_sequence 配置值为 false
+ * 原因:ODC 未实现 PostgresSequenceExtension,与 SQL Server 保持一致
+ */
+ @Test
+ public void testMigrationScript_supportSequence_isFalse() throws Exception {
+ String content = readMigrationScript();
+ Pattern pattern = Pattern.compile(
+ "values\\s*\\(\\s*'support_sequence'\\s*,\\s*'POSTGRESQL'\\s*,\\s*'([^']+)'",
+ Pattern.CASE_INSENSITIVE);
+ Matcher matcher = pattern.matcher(content);
+
+ Assert.assertTrue("Should find support_sequence config for POSTGRESQL", matcher.find());
+ Assert.assertEquals("support_sequence should be 'false' for POSTGRESQL (not implemented)",
+ "false", matcher.group(1).toLowerCase());
+ }
+
+ /**
+ * Test case 12.4: Verify support_type is set to false for PostgreSQL
+ * ODC doesn't implement TypeExtensionPoint for PostgreSQL yet (same as SQL Server)
+ *
+ * 测试目标:验证 support_type 配置值为 false
+ * 原因:ODC 未实现 PostgresTypeExtension,与 SQL Server 保持一致
+ */
+ @Test
+ public void testMigrationScript_supportType_isFalse() throws Exception {
+ String content = readMigrationScript();
+ Pattern pattern = Pattern.compile(
+ "values\\s*\\(\\s*'support_type'\\s*,\\s*'POSTGRESQL'\\s*,\\s*'([^']+)'",
+ Pattern.CASE_INSENSITIVE);
+ Matcher matcher = pattern.matcher(content);
+
+ Assert.assertTrue("Should find support_type config for POSTGRESQL", matcher.find());
+ Assert.assertEquals("support_type should be 'false' for POSTGRESQL (not implemented)",
+ "false", matcher.group(1).toLowerCase());
+ }
+
/**
* Test case 13: Verify the number of PostgreSQL configs is sufficient
*
From 7d7eff0097dc86f040c241560981e5c12c6ec7bb Mon Sep 17 00:00:00 2001
From: Sun Jian <921465802@qq.com>
Date: Wed, 18 Mar 2026 16:36:33 +0800
Subject: [PATCH 22/25] fix(sql-execute): add fallback for DB duration display
when executeMicroseconds is null
When PostgreSQL/SQLServer/Doris execute SQL, the TraceExtension.getExecuteDetail()
returns empty SqlExecTime object (executeMicroseconds=null) because these databases
don't have built-in trace mechanism like OceanBase/MySQL's show profile.
Original implementation skipped creating DB_SERVER_EXECUTE_SQL stage when
executeMicroseconds is null, causing frontend to display '-' for DB duration.
This fix adds fallback logic to use Execute stage time as approximate DB execution
time, ensuring frontend can always display DB duration correctly.
Fixes: E-002 PostgreSQL DB duration shows '-'
Also benefits: SQLServer, Doris, MySQL(profiling=OFF), Oracle(exception)
---
.../service/session/OdcStatementCallBack.java | 16 ++
.../OdcStatementCallBackTraceStageTest.java | 140 ++++++++++++++++++
2 files changed, 156 insertions(+)
create mode 100644 server/odc-service/src/test/java/com/oceanbase/odc/service/session/OdcStatementCallBackTraceStageTest.java
diff --git a/server/odc-service/src/main/java/com/oceanbase/odc/service/session/OdcStatementCallBack.java b/server/odc-service/src/main/java/com/oceanbase/odc/service/session/OdcStatementCallBack.java
index a1622dde8a..03ddd3c6c0 100644
--- a/server/odc-service/src/main/java/com/oceanbase/odc/service/session/OdcStatementCallBack.java
+++ b/server/odc-service/src/main/java/com/oceanbase/odc/service/session/OdcStatementCallBack.java
@@ -486,6 +486,22 @@ private SqlExecTime getTraceIdAndAndSetStage(Statement statement, TraceWatch tra
private void setExecuteTraceStage(TraceWatch traceWatch, SqlExecTime executeDetails, StopWatch stopWatch) {
if (executeDetails.getExecuteMicroseconds() == null) {
+ // Fallback: Use Execute stage time as approximate DB execution time
+ // This is useful for databases like PostgreSQL and SQLServer that don't provide
+ // built-in trace mechanism to get detailed execution time.
+ List executeStages = traceWatch.getByTaskName(SqlExecuteStages.EXECUTE);
+ if (executeStages != null && !executeStages.isEmpty()) {
+ long executeTimeMicros = executeStages.get(0).getTime(TimeUnit.MICROSECONDS);
+ try (EditableTraceStage dbServerExecute =
+ traceWatch.startEditableStage(SqlExecuteStages.DB_SERVER_EXECUTE_SQL)) {
+ dbServerExecute.setStartTime(executeStages.get(0).getStartTime(), TimeUnit.MICROSECONDS);
+ dbServerExecute.setTime(executeTimeMicros, TimeUnit.MICROSECONDS);
+ }
+ try (EditableTraceStage calculateDuration =
+ traceWatch.startEditableStage(SqlExecuteStages.CALCULATE_DURATION)) {
+ calculateDuration.adapt(stopWatch);
+ }
+ }
return;
} else if (executeDetails.getLastPacketSendTimestamp() == null
|| executeDetails.getLastPacketResponseTimestamp() == null) {
diff --git a/server/odc-service/src/test/java/com/oceanbase/odc/service/session/OdcStatementCallBackTraceStageTest.java b/server/odc-service/src/test/java/com/oceanbase/odc/service/session/OdcStatementCallBackTraceStageTest.java
new file mode 100644
index 0000000000..db32756232
--- /dev/null
+++ b/server/odc-service/src/test/java/com/oceanbase/odc/service/session/OdcStatementCallBackTraceStageTest.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.service.session;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import com.oceanbase.odc.common.util.TraceStage;
+import com.oceanbase.odc.common.util.TraceWatch;
+import com.oceanbase.odc.common.util.TraceWatch.EditableTraceStage;
+import com.oceanbase.odc.core.sql.execute.SqlExecuteStages;
+
+/**
+ * Test case for the fallback logic when executeMicroseconds is null
+ *
+ *
+ * This test verifies that when PostgreSQL or SQLServer executes SQL, the Execute stage time can be
+ * used as approximate DB execution time.
+ *
+ * @author ODC Team
+ * @date 2025-03
+ * @since ODC_release_4.3.5
+ */
+public class OdcStatementCallBackTraceStageTest {
+
+ /**
+ * Test that Execute stage can have DB Server Execute SQL as a subStage
+ */
+ @Test
+ public void testExecuteStageCanHaveDBServerSubStage() throws IOException {
+ try (TraceWatch tw = new TraceWatch()) {
+ // Simulate Execute stage with DB Server subStage
+ try (TraceStage executeStage = tw.start(SqlExecuteStages.EXECUTE)) {
+ try (TraceStage dbStage = tw.startEditableStage(SqlExecuteStages.DB_SERVER_EXECUTE_SQL)) {
+ // DB execution happens here
+ }
+ }
+
+ // Verify Execute stage exists
+ List executeStages = tw.getByTaskName(SqlExecuteStages.EXECUTE);
+ Assert.assertEquals("Execute stage should exist", 1, executeStages.size());
+
+ // Verify DB Server Execute SQL is subStage of Execute
+ List dbStages = tw.getByTaskName(SqlExecuteStages.DB_SERVER_EXECUTE_SQL);
+ Assert.assertEquals("DB Server Execute SQL stage should exist", 1, dbStages.size());
+ }
+ }
+
+ /**
+ * Test that Execute stage time is measurable and can be used as DB time approximation
+ */
+ @Test
+ public void testExecuteStageTimeIsMeasurable() throws IOException, InterruptedException {
+ try (TraceWatch tw = new TraceWatch()) {
+ try (TraceStage executeStage = tw.start(SqlExecuteStages.EXECUTE)) {
+ Thread.sleep(10); // 10ms
+ }
+
+ List executeStages = tw.getByTaskName(SqlExecuteStages.EXECUTE);
+ Assert.assertEquals("Execute stage should exist", 1, executeStages.size());
+
+ long executeTimeMicros = executeStages.get(0).getTime(TimeUnit.MICROSECONDS);
+ Assert.assertTrue("Execute time should be >= 10ms", executeTimeMicros >= 10000);
+ }
+ }
+
+ /**
+ * Test PostgreSQL scenario: when executeMicroseconds is null, fallback to Execute stage time
+ */
+ @Test
+ public void testPostgreSQLFallbackScenario() throws IOException, InterruptedException {
+ try (TraceWatch tw = new TraceWatch()) {
+ long executeTimeMicros;
+
+ // Simulate Execute stage
+ try (TraceStage executeStage = tw.start(SqlExecuteStages.EXECUTE)) {
+ Thread.sleep(15); // 15ms simulated DB execution
+
+ // Fallback: copy Execute time to DB Server stage
+ executeTimeMicros = executeStage.getTime(TimeUnit.MICROSECONDS);
+ // Note: at this point, we can't get the time until stage is stopped
+ }
+
+ // Get the Execute stage time after it's stopped
+ List executeStages = tw.getByTaskName(SqlExecuteStages.EXECUTE);
+ executeTimeMicros = executeStages.get(0).getTime(TimeUnit.MICROSECONDS);
+
+ // Now create the DB Server Execute SQL stage with that time
+ try (TraceStage execStage = tw.start(SqlExecuteStages.EXECUTE)) {
+ try (EditableTraceStage dbStage = tw.startEditableStage(SqlExecuteStages.DB_SERVER_EXECUTE_SQL)) {
+ // Set DB time to Execute time (fallback behavior)
+ dbStage.setTime(executeTimeMicros, TimeUnit.MICROSECONDS);
+ }
+ }
+
+ // Verify DB Server Execute SQL stage has time set
+ List dbStages = tw.getByTaskName(SqlExecuteStages.DB_SERVER_EXECUTE_SQL);
+ Assert.assertEquals("DB Server Execute SQL stage should exist", 1, dbStages.size());
+
+ long dbTime = dbStages.get(0).getTime(TimeUnit.MICROSECONDS);
+ Assert.assertEquals("DB time should match Execute time", executeTimeMicros, dbTime);
+ }
+ }
+
+ /**
+ * Test that EditableTraceStage can set custom time
+ */
+ @Test
+ public void testEditableTraceStageCanSetTime() throws IOException {
+ try (TraceWatch tw = new TraceWatch()) {
+ try (TraceStage executeStage = tw.start(SqlExecuteStages.EXECUTE)) {
+ try (EditableTraceStage dbStage = tw.startEditableStage(SqlExecuteStages.DB_SERVER_EXECUTE_SQL)) {
+ dbStage.setTime(5000, TimeUnit.MICROSECONDS); // 5ms
+ }
+ }
+
+ List dbStages = tw.getByTaskName(SqlExecuteStages.DB_SERVER_EXECUTE_SQL);
+ Assert.assertEquals("DB stage should exist", 1, dbStages.size());
+ Assert.assertEquals("DB time should be 5000 microseconds", 5000L,
+ dbStages.get(0).getTime(TimeUnit.MICROSECONDS));
+ }
+ }
+}
From 524f8514ddef3046a022ff857219201b3ea34a88 Mon Sep 17 00:00:00 2001
From: actiontech-zihan
Date: Wed, 20 May 2026 12:07:21 +0000
Subject: [PATCH 23/25] fix: add V_4_3_4_14 to repair PG version_diff_config
idempotency (issue #850, compat-RISK-1)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Refs: dms-ee#850
Risk: compat-RISK-1
data-upgrade: B-V_4_3_4_14__fix_postgresql_version_diff_config_idempotent
存量 ODC 部署因 V_4_3_4_13 末尾 `ON DUPLICATE KEY UPDATE config_key=config_key`
(自我赋值即 NOOP),无法把已存在的 PostgreSQL 三项配置 support_trigger /
support_sequence / support_type 从 'true' 覆盖为 'false',导致升级后 PG 后端
PostgreSQLFeatures 仍按 true 加载,与前端收敛不一致(compat_risks.md §1
compat-RISK-1 / design.md §8.4)。
新增独立 Flyway DML 脚本 V_4_3_4_14,对存量行执行 UPDATE:
- WHERE `db_mode` = 'POSTGRESQL':仅命中 PG,不影响 MySQL/Oracle 等其它 db_mode
- WHERE `config_key` IN ('support_trigger','support_sequence','support_type'):
仅修复三项 bug 配置,不影响 PG 其它 config_key(support_view / support_function 等)
- WHERE `config_value` = 'true':仅修复老错误状态,二次执行 / 全新部署均零变更
(幂等),不抹掉运维已手工修正的状态
V_4_3_4_13 文本未改动(Flyway checksum 约束);本次新增 SQL 与 V_4_3_4_13 同
目录,Flyway 同 location 扫描,按版本号顺序执行。
---
...fix_postgresql_version_diff_config_idempotent.sql | 12 ++++++++++++
1 file changed, 12 insertions(+)
create mode 100644 server/odc-migrate/src/main/resources/migrate/common/V_4_3_4_14__fix_postgresql_version_diff_config_idempotent.sql
diff --git a/server/odc-migrate/src/main/resources/migrate/common/V_4_3_4_14__fix_postgresql_version_diff_config_idempotent.sql b/server/odc-migrate/src/main/resources/migrate/common/V_4_3_4_14__fix_postgresql_version_diff_config_idempotent.sql
new file mode 100644
index 0000000000..f640ae549a
--- /dev/null
+++ b/server/odc-migrate/src/main/resources/migrate/common/V_4_3_4_14__fix_postgresql_version_diff_config_idempotent.sql
@@ -0,0 +1,12 @@
+-- Fix V_4_3_4_13 idempotency bug:
+-- early versions of V_4_3_4_13 inserted support_trigger/support_sequence/support_type with
+-- config_value='true', and the trailing `ON DUPLICATE KEY UPDATE config_key=config_key`
+-- (self-assignment) prevents the corrected SQL ('false') from overwriting existing rows.
+-- This migration explicitly UPDATEs only the three PG-specific config keys whose value is still 'true'.
+-- Refs: dms-ee#850, compat-RISK-1
+
+UPDATE `odc_version_diff_config`
+ SET `config_value` = 'false'
+ WHERE `db_mode` = 'POSTGRESQL'
+ AND `config_key` IN ('support_trigger', 'support_sequence', 'support_type')
+ AND `config_value` = 'true';
From 6dadfed730a75a0e57cb9acd118df9761825cb4b Mon Sep 17 00:00:00 2001
From: actiontech-zihan
Date: Wed, 20 May 2026 17:01:27 +0000
Subject: [PATCH 24/25] fix(postgres): fallback catalog name when DMS creates
PG datasource without catalog_name (issue #850)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bug: DMS 创建 PG 数据源时仅传 default_schema(典型值 public)而不传 catalogName,
ODC 后端 PostgresConnectionExtension#generateJdbcUrl 抛
`IllegalArgumentException: catalog name can not be null`,导致
DatabaseService.syncDataSourceSchemas 100% 失败、前端资源树 PG 节点没有 caret-down,
无法展开到 schema 层(dms-ee#850 / Task-001 烟雾测试 EXC-2)。
Root cause: PG 的 JDBC URL 形如 jdbc:postgresql://host:port/?currentSchema=,
catalog 必须非空,但 ODC `ConnectionConfig` 把 catalogName / defaultSchema 分离持久化,
上游(DMS)侧只填了 default_schema,未提供 database name;ODC 当前没有兜底。
Fix: 在 OBConsoleDataSourceFactory 新增 `resolveEffectiveCatalogName(dialectType,
catalogName, defaultSchema)` 静态方法做 PG 专属兜底——仅当 dialectType=POSTGRESQL 且
catalogName 为空时生效:
1. defaultSchema 非空且 !equalsIgnoreCase("public") → 用 defaultSchema 兜底(兼容
用户在 default_schema 字段中实际填了 database 名的场景);
2. 否则使用 PG 标准内置数据库 `postgres`(POSTGRESQL_DEFAULT_DATABASE,
在所有 PG 标准安装中默认存在)。
不直接用 defaultSchema=public 作 catalog 兜底,因为 PG 中 public 是 schema 名而不是
database 名,强行用之会抛 FATAL: database "public" does not exist。
对其他数据源类型(MySQL/Oracle/SQLServer/OceanBase 等)保持原行为不变:catalogName
原样透传,不影响其既有 JDBC URL 生成逻辑(这些 dialect 的 ConnectionExtension 未对
catalogName 做非空校验)。
Files:
- odc-core/OdcConstants: 新增 POSTGRESQL_DEFAULT_DATABASE = "postgres" 常量
- odc-service/OBConsoleDataSourceFactory: getJdbcUrlProperties() 通过新增的
resolveEffectiveCatalogName 做兜底;保留原 connectionConfig 持久化字段不变
- odc-service/ConnectionTesting#getJdbcUrlProperties: 同步走 resolveEffectiveCatalogName
兜底,让测试连接(POST /api/v2/datasource/.../connection_test)也修复
- odc-service/DataSourceInfoMapper#getJdbcUrl: DLM 数据迁移任务路径同步兜底
- odc-service test: 新增 OBConsoleDataSourceFactoryTest 12 条用例,覆盖
PG/MySQL/Oracle/OBMysql/SqlServer 各种 catalog/schema 组合,含 issue #850 现场复现
Validation:
- mvn -pl server/odc-core install + mvn -pl server/odc-service compile 全通过
- 单测 OBConsoleDataSourceFactoryTest 12/12 OK(独立 JUnit runner)
- ODC 重启后日志 `Create datasource success, jdbcUrl:
jdbc:postgresql://10.186.16.126:5433/postgres?...¤tSchema=public&...`
证明 jdbc URL 生成成功;之前 `catalog name can not be null` 错误**不再出现**
Refs: dms-ee#850, EXC-2 (Task-001 烟雾测试)
---
.../core/shared/constant/OdcConstants.java | 12 ++
.../service/connection/ConnectionTesting.java | 6 +-
.../odc/service/dlm/DataSourceInfoMapper.java | 7 +-
.../factory/OBConsoleDataSourceFactory.java | 54 +++++++-
.../OBConsoleDataSourceFactoryTest.java | 118 ++++++++++++++++++
5 files changed, 194 insertions(+), 3 deletions(-)
create mode 100644 server/odc-service/src/test/java/com/oceanbase/odc/service/session/factory/OBConsoleDataSourceFactoryTest.java
diff --git a/server/odc-core/src/main/java/com/oceanbase/odc/core/shared/constant/OdcConstants.java b/server/odc-core/src/main/java/com/oceanbase/odc/core/shared/constant/OdcConstants.java
index 6df42d031e..6dad0d3917 100644
--- a/server/odc-core/src/main/java/com/oceanbase/odc/core/shared/constant/OdcConstants.java
+++ b/server/odc-core/src/main/java/com/oceanbase/odc/core/shared/constant/OdcConstants.java
@@ -70,6 +70,18 @@ public class OdcConstants {
public static final String MYSQL_DEFAULT_SCHEMA = "information_schema";
public static final String POSTGRESQL_DEFAULT_SCHEMA = "public";
+ /**
+ * PostgreSQL 默认数据库(catalog)名称。
+ *
+ *
+ * 对其他数据源类型(MySQL/Oracle/SQLServer/OceanBase 等)保持原行为不变,{@code catalogName} 原样透传, 不影响其既有 JDBC URL
+ * 生成逻辑(这些 dialect 的 ConnectionExtension 未对 catalogName 做非空校验)。
+ *
+ * @param dialectType 数据源类型
+ * @param catalogName 用户配置的 catalog(可空)
+ * @param defaultSchema 经过 {@link #getDefaultSchema(ConnectionConfig)} 处理后的默认 schema
+ * @return 实际用于 JDBC URL 的 catalog 名称
+ * @since 4.3.4 (issue #850)
+ */
+ public static String resolveEffectiveCatalogName(DialectType dialectType, String catalogName,
+ String defaultSchema) {
+ if (StringUtils.isNotBlank(catalogName)) {
+ return catalogName;
+ }
+ if (DialectType.POSTGRESQL == dialectType) {
+ if (StringUtils.isNotBlank(defaultSchema)
+ && !OdcConstants.POSTGRESQL_DEFAULT_SCHEMA.equalsIgnoreCase(defaultSchema)) {
+ return defaultSchema;
+ }
+ return OdcConstants.POSTGRESQL_DEFAULT_DATABASE;
+ }
+ return catalogName;
}
public static String getUsername(@NonNull ConnectionConfig connectionConfig) {
diff --git a/server/odc-service/src/test/java/com/oceanbase/odc/service/session/factory/OBConsoleDataSourceFactoryTest.java b/server/odc-service/src/test/java/com/oceanbase/odc/service/session/factory/OBConsoleDataSourceFactoryTest.java
new file mode 100644
index 0000000000..66ba9c81f1
--- /dev/null
+++ b/server/odc-service/src/test/java/com/oceanbase/odc/service/session/factory/OBConsoleDataSourceFactoryTest.java
@@ -0,0 +1,118 @@
+/*
+ * Copyright (c) 2023 OceanBase.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.oceanbase.odc.service.session.factory;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import com.oceanbase.odc.core.shared.constant.DialectType;
+
+/**
+ * {@link OBConsoleDataSourceFactory#resolveEffectiveCatalogName(DialectType, String, String)} 单元测试。
+ *
+ *
+ * 覆盖 issue #850 中 PG 数据源 {@code catalog name can not be null} 阻塞性 BUG 的修复路径:上游(DMS)创建 PG 数据源时通常只传
+ * {@code default_schema=public} 而不传 {@code catalog_name},导致 ODC 后端
+ * {@code DatabaseService.syncDataSourceSchemas} 100% 失败、前端资源树无法展开。修复方案在 PG 类型 + catalog 为空时
+ * 走如下兜底:defaultSchema 非空且不等于 PG 内置 schema {@code public} → defaultSchema;否则 → PG 内置默认数据库
+ * {@code postgres}。
+ */
+public class OBConsoleDataSourceFactoryTest {
+
+ @Test
+ public void testResolveEffectiveCatalogName_ExplicitCatalog_PostgreSQL_returnsAsIs() {
+ Assert.assertEquals("mydb",
+ OBConsoleDataSourceFactory.resolveEffectiveCatalogName(DialectType.POSTGRESQL, "mydb", "public"));
+ }
+
+ @Test
+ public void testResolveEffectiveCatalogName_ExplicitCatalog_MySQL_returnsAsIs() {
+ Assert.assertEquals("mydb",
+ OBConsoleDataSourceFactory.resolveEffectiveCatalogName(DialectType.MYSQL, "mydb",
+ "information_schema"));
+ }
+
+ @Test
+ public void testResolveEffectiveCatalogName_PG_nullCatalog_publicSchema_fallsBackToPostgresDb() {
+ // 复现 issue #850 现场:DMS 创建 PG 数据源仅传 default_schema=public,catalog 为 null
+ Assert.assertEquals("postgres",
+ OBConsoleDataSourceFactory.resolveEffectiveCatalogName(DialectType.POSTGRESQL, null, "public"));
+ }
+
+ @Test
+ public void testResolveEffectiveCatalogName_PG_nullCatalog_publicSchemaCaseInsensitive_fallsBackToPostgresDb() {
+ Assert.assertEquals("postgres",
+ OBConsoleDataSourceFactory.resolveEffectiveCatalogName(DialectType.POSTGRESQL, null, "Public"));
+ Assert.assertEquals("postgres",
+ OBConsoleDataSourceFactory.resolveEffectiveCatalogName(DialectType.POSTGRESQL, null, "PUBLIC"));
+ }
+
+ @Test
+ public void testResolveEffectiveCatalogName_PG_emptyCatalog_publicSchema_fallsBackToPostgresDb() {
+ Assert.assertEquals("postgres",
+ OBConsoleDataSourceFactory.resolveEffectiveCatalogName(DialectType.POSTGRESQL, "", "public"));
+ Assert.assertEquals("postgres",
+ OBConsoleDataSourceFactory.resolveEffectiveCatalogName(DialectType.POSTGRESQL, " ", "public"));
+ }
+
+ @Test
+ public void testResolveEffectiveCatalogName_PG_nullCatalog_customSchema_usesSchemaAsCatalog() {
+ // 兼容用户在 default_schema 字段中实际填了 database 名的场景
+ Assert.assertEquals("testdb",
+ OBConsoleDataSourceFactory.resolveEffectiveCatalogName(DialectType.POSTGRESQL, null, "testdb"));
+ Assert.assertEquals("appdb",
+ OBConsoleDataSourceFactory.resolveEffectiveCatalogName(DialectType.POSTGRESQL, "", "appdb"));
+ }
+
+ @Test
+ public void testResolveEffectiveCatalogName_PG_nullCatalog_nullSchema_fallsBackToPostgresDb() {
+ Assert.assertEquals("postgres",
+ OBConsoleDataSourceFactory.resolveEffectiveCatalogName(DialectType.POSTGRESQL, null, null));
+ Assert.assertEquals("postgres",
+ OBConsoleDataSourceFactory.resolveEffectiveCatalogName(DialectType.POSTGRESQL, "", ""));
+ }
+
+ @Test
+ public void testResolveEffectiveCatalogName_MySQL_nullCatalog_doesNotFallback() {
+ // 不能影响其他数据源类型——MySQL 不强校验 catalog
+ Assert.assertNull(
+ OBConsoleDataSourceFactory.resolveEffectiveCatalogName(DialectType.MYSQL, null, "information_schema"));
+ }
+
+ @Test
+ public void testResolveEffectiveCatalogName_Oracle_nullCatalog_doesNotFallback() {
+ Assert.assertNull(
+ OBConsoleDataSourceFactory.resolveEffectiveCatalogName(DialectType.ORACLE, null, "ORCL"));
+ }
+
+ @Test
+ public void testResolveEffectiveCatalogName_OBMySQL_nullCatalog_doesNotFallback() {
+ Assert.assertNull(
+ OBConsoleDataSourceFactory.resolveEffectiveCatalogName(DialectType.OB_MYSQL, null, "test"));
+ }
+
+ @Test
+ public void testResolveEffectiveCatalogName_SqlServer_nullCatalog_doesNotFallback() {
+ Assert.assertNull(
+ OBConsoleDataSourceFactory.resolveEffectiveCatalogName(DialectType.SQL_SERVER, null, "master"));
+ }
+
+ @Test
+ public void testResolveEffectiveCatalogName_NullDialect_emptyCatalog_returnsEmpty() {
+ Assert.assertNull(
+ OBConsoleDataSourceFactory.resolveEffectiveCatalogName(null, null, "any"));
+ }
+}
From 087f5a8a96849f2109764c6dd612455fb5ec1374 Mon Sep 17 00:00:00 2001
From: actiontech-zihan
Date: Fri, 29 May 2026 10:23:18 +0000
Subject: [PATCH 25/25] fix(postgres): always fall back PG catalog to
"postgres" and clean stale databases on JDBC sync failure (issue #850)
Two PG datasource compatibility fixes follow-up the existing #850 work:
1. resolveEffectiveCatalogName(): drop the defaultSchema fallback.
The previous logic returned defaultSchema as catalog when it was non-empty
and not "public", on the assumption that users sometimes put the database
name in default_schema. That assumption breaks for the common case where
default_schema is a real PG schema name (e.g. schema_a) and there is no
PG database named schema_a, which triggers
FATAL: database "schema_a" does not exist
and blocks every sync / connection test for that datasource.
Now we always fall back to OdcConstants.POSTGRESQL_DEFAULT_DATABASE
("postgres") when catalogName is blank. The front-end already keeps
catalogName as a required field for PG datasource, so this is purely a
defence-in-depth path for older imports and third-party callers.
Unit test OBConsoleDataSourceFactoryTest is realigned: the
customSchema_usesSchemaAsCatalog case is renamed to
customSchema_fallsBackToPostgresDb to match the new contract; the other
11 cases (explicit catalog returns as-is; non-PG dialects untouched; etc.)
continue to assert the previous behaviour.
2. DatabaseService.handleSyncException(): also clean connect_database on
generic JDBC connection failure.
The OceanBase-specific clauses (cluster not exist / No tenants found)
were the only ones that triggered deleteDatabaseIfInstanceNotExists().
When PG syncing failed because the catalog did not exist (or the network
was unreachable, or auth failed), the old database rows kept
is_existed=1 and the user could not refresh the list from the UI.
We add a third branch matching common JDBC connection failure markers
(CannotGetJdbcConnectionException / "Failed to obtain JDBC Connection" /
"FATAL: database") and reuse the same cleanup helper. Failed-reason
stays UNKNOWN so the user sees the raw JDBC error in
connect_sync_history but the stale database list is cleared.
Refs: dms-ee#850
---
.../connection/database/DatabaseService.java | 10 ++++++
.../factory/OBConsoleDataSourceFactory.java | 31 ++++++++++---------
.../OBConsoleDataSourceFactoryTest.java | 16 +++++-----
3 files changed, 35 insertions(+), 22 deletions(-)
diff --git a/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/database/DatabaseService.java b/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/database/DatabaseService.java
index 4704f56ed0..318bdf4390 100644
--- a/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/database/DatabaseService.java
+++ b/server/odc-service/src/main/java/com/oceanbase/odc/service/connection/database/DatabaseService.java
@@ -1275,6 +1275,16 @@ private void handleSyncException(@NonNull Exception ex, @NonNull Long dataSource
.containsIgnoreCase(errorMessage, "tenant expected 1 but was")) {
failedReason = ConnectionSyncErrorReason.TENANT_NOT_EXISTS;
deleteDatabaseIfInstanceNotExists(dataSourceId, organization.getType());
+ } else if (StringUtils.containsIgnoreCase(errorMessage, "CannotGetJdbcConnectionException")
+ || StringUtils.containsIgnoreCase(errorMessage, "Failed to obtain JDBC Connection")
+ || StringUtils.containsIgnoreCase(errorMessage, "FATAL: database")) {
+ // PG (and other JDBC dialects) connection failure: catalog 不存在 / 网络不通 / 认证失败等,
+ // 走通用 JDBC 连接失败兜底,把该数据源下旧的 connect_database 记录标记为 not-existed,
+ // 让前端"同步"按钮重新触发拉取,避免界面上残留已不存在的数据库。
+ log.warn(
+ "JDBC connection failed during sync, marking all databases as not-existed for dataSourceId={}",
+ dataSourceId);
+ deleteDatabaseIfInstanceNotExists(dataSourceId, organization.getType());
}
connectionSyncHistoryService.upsert(dataSourceId, ConnectionSyncResult.FAILURE, organization.getId(),
failedReason, errorMessage);
diff --git a/server/odc-service/src/main/java/com/oceanbase/odc/service/session/factory/OBConsoleDataSourceFactory.java b/server/odc-service/src/main/java/com/oceanbase/odc/service/session/factory/OBConsoleDataSourceFactory.java
index 0b096b7d62..ce62cd1643 100644
--- a/server/odc-service/src/main/java/com/oceanbase/odc/service/session/factory/OBConsoleDataSourceFactory.java
+++ b/server/odc-service/src/main/java/com/oceanbase/odc/service/session/factory/OBConsoleDataSourceFactory.java
@@ -138,19 +138,20 @@ this.serviceName, resolveEffectiveCatalogName(
* {@code DatabaseService.syncDataSourceSchemas} 100% 失败、前端资源树无法展开。
*
*