From 2bdab48d78cb0b7f64925dc1342aaf84c8feff9a Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 23 Jul 2026 23:59:03 +0800 Subject: [PATCH] fix(oracle): support locked edits and OceanBase sources --- .../oceanbaseoracle/OceanBaseOracleAgent.java | 109 ++++++++++++++ .../OceanBaseOracleAgentTest.java | 133 ++++++++++++++++++ .../sql/editableQueryHiddenKeys.spec.ts | 12 ++ .../lib/__tests__/sql/semantic/model.spec.ts | 7 + apps/desktop/src/lib/sql/semantic/model.ts | 2 +- apps/desktop/src/lib/sql/sqlAnalysis.ts | 6 +- crates/dbx-core/src/sql_editability.rs | 90 +++++++++++- packages/app-tests/sqlAnalysis.test.ts | 40 ++++++ 8 files changed, 394 insertions(+), 5 deletions(-) diff --git a/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java b/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java index f8339ba119..6e8a33cf83 100644 --- a/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java +++ b/agents/drivers/oceanbase-oracle/src/main/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgent.java @@ -12,6 +12,7 @@ import com.dbx.agent.JdbcIdentifiers; import com.dbx.agent.MultiSessionJsonRpcServer; import com.dbx.agent.MetadataListConstraints; import com.dbx.agent.ObjectInfo; +import com.dbx.agent.ObjectSource; import com.dbx.agent.TableInfo; import com.dbx.agent.TriggerInfo; @@ -266,6 +267,114 @@ public final class OceanBaseOracleAgent extends ConfiguredJdbcAgent { return result; } + @Override + public ObjectSource getObjectSource(String schema, String name, String objectType) { + return unchecked(() -> { + String owner = normalizeSchema(schema); + String normalizedType = normalizeObjectSourceType(objectType); + String source; + SQLException metadataError = null; + try { + source = queryDbmsMetadataSource(owner, name, normalizedType); + } catch (SQLException e) { + metadataError = e; + source = null; + } + + if ((source == null || source.trim().isEmpty()) && supportsDictionarySource(normalizedType)) { + try { + // OceanBase versions and tenant privileges differ in DBMS_METADATA coverage. + // Oracle-compatible dictionary views keep schema compare and source editing usable. + source = queryDictionarySource(owner, name, normalizedType); + } catch (SQLException fallbackError) { + if (metadataError != null) { + metadataError.addSuppressed(fallbackError); + throw metadataError; + } + throw fallbackError; + } + } + if (metadataError != null && (source == null || source.trim().isEmpty())) { + throw metadataError; + } + return new ObjectSource(name, normalizedType, owner, source == null ? "" : source); + }); + } + + private String queryDbmsMetadataSource(String owner, String name, String objectType) throws SQLException { + String sql = "SELECT DBMS_METADATA.GET_DDL(?, ?, ?) FROM DUAL"; + try (var stmt = requireConnection().prepareStatement(sql)) { + stmt.setString(1, objectType); + stmt.setString(2, name); + stmt.setString(3, owner); + try (ResultSet rs = stmt.executeQuery()) { + return rs.next() ? rs.getString(1) : null; + } + } + } + + private String queryDictionarySource(String owner, String name, String objectType) throws SQLException { + if ("VIEW".equals(objectType)) { + String sql = "SELECT TEXT FROM ALL_VIEWS WHERE OWNER = ? AND VIEW_NAME = ?"; + try (var stmt = requireConnection().prepareStatement(sql)) { + stmt.setString(1, owner); + stmt.setString(2, name); + try (ResultSet rs = stmt.executeQuery()) { + return rs.next() ? rs.getString(1) : null; + } + } + } + + String sourceType = switch (objectType) { + case "PROCEDURE", "FUNCTION", "PACKAGE", "TRIGGER", "TYPE" -> objectType; + case "PACKAGE_BODY" -> "PACKAGE BODY"; + case "TYPE_BODY" -> "TYPE BODY"; + default -> throw new IllegalArgumentException("Unsupported object type: " + objectType); + }; + String sql = "SELECT TEXT FROM ALL_SOURCE WHERE OWNER = ? AND NAME = ? AND TYPE = ? ORDER BY LINE"; + StringBuilder source = new StringBuilder(); + try (var stmt = requireConnection().prepareStatement(sql)) { + stmt.setString(1, owner); + stmt.setString(2, name); + stmt.setString(3, sourceType); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + String line = rs.getString(1); + if (line != null) { + source.append(line); + } + } + } + } + return editableOracleSource(source.toString()); + } + + private static String normalizeObjectSourceType(String objectType) { + String normalized = objectType == null + ? "" + : objectType.trim().toUpperCase(Locale.ROOT).replace(' ', '_'); + return switch (normalized) { + case "VIEW", "MATERIALIZED_VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "SEQUENCE", + "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY" -> normalized; + default -> throw new IllegalArgumentException("Unsupported object type: " + objectType); + }; + } + + private static boolean supportsDictionarySource(String objectType) { + return switch (objectType) { + case "VIEW", "PROCEDURE", "FUNCTION", "TRIGGER", "PACKAGE", "PACKAGE_BODY", "TYPE", "TYPE_BODY" -> true; + default -> false; + }; + } + + private static String editableOracleSource(String source) { + String trimmed = source.trim(); + if (trimmed.isEmpty() || trimmed.regionMatches(true, 0, "CREATE ", 0, "CREATE ".length())) { + return trimmed; + } + return "CREATE OR REPLACE " + trimmed; + } + private static String placeholders(int count) { return String.join(", ", java.util.Collections.nCopies(count, "?")); } diff --git a/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java b/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java index eb3b9e1dd0..e49ef2d3d4 100644 --- a/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java +++ b/agents/drivers/oceanbase-oracle/src/test/java/com/dbx/agent/oceanbaseoracle/OceanBaseOracleAgentTest.java @@ -5,6 +5,7 @@ import com.dbx.agent.ConnectParams; import com.dbx.agent.ExecuteQueryOptions; import com.dbx.agent.MetadataListConstraints; import com.dbx.agent.ObjectInfo; +import com.dbx.agent.ObjectSource; import com.dbx.agent.QueryPageOptions; import com.dbx.agent.TableInfo; import com.dbx.agent.test.TestSupport; @@ -17,6 +18,7 @@ import java.lang.reflect.Proxy; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.sql.SQLException; import java.sql.Statement; import java.util.ArrayList; import java.util.List; @@ -161,6 +163,89 @@ class OceanBaseOracleAgentTest { Assertions.assertTrue(sql.get(0).contains("ROWNUM <= ?"), sql.get(0)); } + @Test + void readsViewDdlWithDbmsMetadataForSchemaCompare() { + List sql = new ArrayList<>(); + List params = new ArrayList<>(); + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, objectSourceConnection( + sql, + params, + resultSet( + new String[]{"DDL"}, + new Object[][]{{"CREATE OR REPLACE VIEW \"APP\".\"ACTIVE_USERS\" AS SELECT ID FROM USERS"}} + ) + )); + + ObjectSource source = agent.getObjectSource("app", "ACTIVE_USERS", "VIEW"); + + Assertions.assertEquals("VIEW", source.getObject_type()); + Assertions.assertEquals("app", source.getSchema()); + Assertions.assertTrue(source.getSource().startsWith("CREATE OR REPLACE VIEW"), source.getSource()); + Assertions.assertEquals(List.of("VIEW", "ACTIVE_USERS", "app"), params); + Assertions.assertTrue(sql.get(0).contains("DBMS_METADATA.GET_DDL"), sql.get(0)); + } + + @Test + void fallsBackToAllViewsWhenDbmsMetadataIsUnavailable() { + List sql = new ArrayList<>(); + List params = new ArrayList<>(); + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, objectSourceFallbackConnection( + sql, + params, + resultSet(new String[]{"TEXT"}, new Object[][]{{"SELECT ID FROM USERS"}}) + )); + + ObjectSource source = agent.getObjectSource("APP", "ACTIVE_USERS", "VIEW"); + + Assertions.assertEquals("SELECT ID FROM USERS", source.getSource()); + Assertions.assertEquals(List.of("VIEW", "ACTIVE_USERS", "APP", "APP", "ACTIVE_USERS"), params); + Assertions.assertTrue(sql.get(1).contains("ALL_VIEWS"), sql.get(1)); + } + + @Test + void fallsBackToAllSourceForOracleRoutineAndPackageTypes() { + for (String[] object : new String[][]{ + {"PROCEDURE", "PROCEDURE"}, + {"FUNCTION", "FUNCTION"}, + {"PACKAGE", "PACKAGE"}, + {"PACKAGE_BODY", "PACKAGE BODY"} + }) { + List sql = new ArrayList<>(); + List params = new ArrayList<>(); + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + TestSupport.setPrivateConnection(agent, objectSourceFallbackConnection( + sql, + params, + resultSet( + new String[]{"TEXT"}, + new Object[][]{{object[1] + " ACCOUNT_API AS\n"}, {"END ACCOUNT_API;\n"}} + ) + )); + + ObjectSource source = agent.getObjectSource("APP", "ACCOUNT_API", object[0]); + + Assertions.assertEquals(object[0], source.getObject_type()); + Assertions.assertTrue(source.getSource().startsWith("CREATE OR REPLACE " + object[1]), source.getSource()); + Assertions.assertEquals(object[1], params.get(params.size() - 1)); + Assertions.assertTrue(sql.get(1).contains("ALL_SOURCE"), sql.get(1)); + Assertions.assertTrue(sql.get(1).contains("ORDER BY LINE"), sql.get(1)); + } + } + + @Test + void rejectsUnsupportedObjectSourceTypesBeforeQuerying() { + OceanBaseOracleAgent agent = new OceanBaseOracleAgent(); + + IllegalArgumentException error = Assertions.assertThrows( + IllegalArgumentException.class, + () -> agent.getObjectSource("APP", "USERS", "TABLE") + ); + + Assertions.assertTrue(error.getMessage().contains("Unsupported object type: TABLE"), error.getMessage()); + } + @Test void getColumnsIncludesDefaultAndCommentMetadata() { List sql = new ArrayList<>(); @@ -261,6 +346,54 @@ class OceanBaseOracleAgentTest { }); } + private static Connection objectSourceConnection(List sql, List params, ResultSet resultSet) { + PreparedStatement statement = objectSourceStatement(params, resultSet, false); + return proxy(Connection.class, (method, args) -> { + if ("prepareStatement".equals(method.getName())) { + sql.add(String.valueOf(args[0])); + return statement; + } + if ("isClosed".equals(method.getName())) { + return false; + } + return defaultValue(method.getReturnType()); + }); + } + + private static Connection objectSourceFallbackConnection(List sql, List params, ResultSet fallbackResultSet) { + int[] statementIndex = {0}; + return proxy(Connection.class, (method, args) -> { + if ("prepareStatement".equals(method.getName())) { + sql.add(String.valueOf(args[0])); + boolean fail = statementIndex[0]++ == 0; + return objectSourceStatement(params, fallbackResultSet, fail); + } + if ("isClosed".equals(method.getName())) { + return false; + } + return defaultValue(method.getReturnType()); + }); + } + + private static PreparedStatement objectSourceStatement(List params, ResultSet resultSet, boolean fail) { + return proxy(PreparedStatement.class, (method, args) -> { + if ("executeQuery".equals(method.getName())) { + if (fail) { + throw new SQLException("DBMS_METADATA is unavailable"); + } + return resultSet; + } + if ("setString".equals(method.getName())) { + params.add(String.valueOf(args[1])); + return null; + } + if ("close".equals(method.getName())) { + return null; + } + return defaultValue(method.getReturnType()); + }); + } + private static Connection executionConnection(List sql) { Statement statement = proxy(Statement.class, (method, args) -> { if ("execute".equals(method.getName())) { diff --git a/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts b/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts index 38c2177bf3..8ba2e16a87 100644 --- a/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/editableQueryHiddenKeys.spec.ts @@ -64,6 +64,18 @@ describe("editable query hidden primary keys", () => { }); }); + it("preserves an Oracle FOR UPDATE clause when appending a hidden row key", () => { + expect( + buildQueryWithHiddenPrimaryKeys({ + sql: "SELECT * FROM APP.USERS FOR UPDATE SKIP LOCKED", + databaseType: "oracle", + primaryKeys: ["__DBX_ROWID"], + existingResultNames: ["ID", "NAME"], + sourceExpressions: { __DBX_ROWID: "ROWIDTOCHAR(ROWID)" }, + })?.sql, + ).toBe('SELECT USERS.*, ROWIDTOCHAR(ROWID) AS "__DBX_PK_0" FROM APP.USERS FOR UPDATE SKIP LOCKED'); + }); + it("qualifies a bare Oracle wildcard when appending a hidden key", () => { expect( buildQueryWithHiddenPrimaryKeys({ diff --git a/apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts b/apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts index f936d9e664..def5467704 100644 --- a/apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/semantic/model.spec.ts @@ -80,6 +80,13 @@ describe("sqlSemanticModel baseline fixtures", () => { expect(model.cursorIntent).toEqual(expect.objectContaining({ kind: "alias_column", qualifierParts: ["b"] })); }); + it("does not treat an Oracle FOR UPDATE clause as a table alias", () => { + const sql = "SELECT * FROM APP.USERS FOR UPDATE SKIP LOCKED"; + const model = buildSqlSemanticModel(sql, sql.length, { databaseType: "oracle" }); + + expect(model.rowSources).toEqual([expect.objectContaining({ name: "USERS", qualifierParts: ["APP"], alias: undefined })]); + }); + it("consumes correlation column lists before parsing later comma-separated sources", () => { const { sql, cursor } = sqlFixtureCursor("SELECT * FROM table_a a(id), table_b b, table_c c WHERE c.|"); const model = buildSqlSemanticModel(sql, cursor, { databaseType: "postgres" }); diff --git a/apps/desktop/src/lib/sql/semantic/model.ts b/apps/desktop/src/lib/sql/semantic/model.ts index d85c3b8a2a..ec697f465a 100644 --- a/apps/desktop/src/lib/sql/semantic/model.ts +++ b/apps/desktop/src/lib/sql/semantic/model.ts @@ -21,7 +21,7 @@ const TABLE_FUNCTION_NAMES = new Set(["table", "xmltable", "json_table", "the", const JOIN_MODIFIERS = new Set(["left", "right", "inner", "outer", "cross", "full", "natural"]); const CLAUSE_BOUNDARIES = new Set(["where", "group", "having", "order", "limit", "offset", "union", "intersect", "except", "on", "set", "values", "returning"]); const FROM_CLAUSE_BOUNDARIES = new Set([...CLAUSE_BOUNDARIES, "window", "qualify", "fetch", "for", "connect", "start", "model"].filter((item) => item !== "on")); -const ALIAS_BLACKLIST = new Set([...CLAUSE_BOUNDARIES, "join", "straight_join", "left", "right", "inner", "outer", "cross", "full", "natural", "as", "select", "from", "with"]); +const ALIAS_BLACKLIST = new Set([...FROM_CLAUSE_BOUNDARIES, "on", "join", "straight_join", "left", "right", "inner", "outer", "cross", "full", "natural", "as", "select", "from", "with"]); const TABLE_TARGET_MODIFIERS = new Set(["lateral", "only"]); const TABLE_FUNCTION_INTRODUCERS = new Set(["from", "join", "straight_join", "apply"]); const TOP_LEVEL_STATEMENT_WORDS = new Set(["select", "insert", "delete", "merge", "create", "alter", "drop", "truncate", "call", "exec", "execute", "grant", "revoke"]); diff --git a/apps/desktop/src/lib/sql/sqlAnalysis.ts b/apps/desktop/src/lib/sql/sqlAnalysis.ts index 8ac164e4ed..6a309e9483 100644 --- a/apps/desktop/src/lib/sql/sqlAnalysis.ts +++ b/apps/desktop/src/lib/sql/sqlAnalysis.ts @@ -132,7 +132,11 @@ export function analyzeEditableQueryEditability(sql: string): QueryEditability { const havingIndex = findTopLevelKeyword(normalized, "HAVING", fromIndex + 4); if (groupIndex >= 0 || havingIndex >= 0) return { editable: false, reason: "aggregation" }; - const fromEnd = firstTopLevelKeywordIndex(normalized, ["WHERE", "ORDER", "LIMIT", "OFFSET", "FETCH"], fromIndex + 4); + const forIndex = findTopLevelKeyword(normalized, "FOR", fromIndex + 4); + // Row-locking FOR clauses change concurrency behavior, not the base-row + // mapping. Output/read-only FOR modes must stay non-editable. + if (forIndex >= 0 && !/^FOR\s+(?:UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b/i.test(normalized.slice(forIndex))) return { editable: false, reason: "complex-source" }; + const fromEnd = firstTopLevelKeywordIndex(normalized, ["WHERE", "ORDER", "LIMIT", "OFFSET", "FETCH", "FOR"], fromIndex + 4); const fromBody = normalized.slice(fromIndex + 4, fromEnd < 0 ? normalized.length : fromEnd).trim(); if (isExternalFromSource(fromBody)) return { editable: false, reason: "external-source" }; const sources = parseFromSources(fromBody); diff --git a/crates/dbx-core/src/sql_editability.rs b/crates/dbx-core/src/sql_editability.rs index 06b9b11014..4cffe7f095 100644 --- a/crates/dbx-core/src/sql_editability.rs +++ b/crates/dbx-core/src/sql_editability.rs @@ -169,9 +169,18 @@ pub fn analyze_editable_query_editability(sql: &str) -> QueryEditability { } let from_body_start = from_index + "FROM".len(); - let from_end = - first_top_level_keyword_index(&normalized, &["WHERE", "ORDER", "LIMIT", "OFFSET", "FETCH"], from_body_start) - .unwrap_or(normalized.len()); + let for_index = find_top_level_keyword(&normalized, "FOR", from_body_start); + // Row-locking FOR clauses change concurrency behavior, not the base-row + // mapping. Output/read-only FOR modes must stay non-editable. + if for_index.is_some_and(|index| !is_row_lock_clause(&normalized[index..])) { + return not_editable(QueryEditabilityReason::ComplexSource); + } + let from_end = first_top_level_keyword_index( + &normalized, + &["WHERE", "ORDER", "LIMIT", "OFFSET", "FETCH", "FOR"], + from_body_start, + ) + .unwrap_or(normalized.len()); let from_body = normalized[from_body_start..from_end].trim(); if is_external_from_source(from_body) { return not_editable(QueryEditabilityReason::ExternalSource); @@ -581,6 +590,26 @@ fn table_source_terminator_at(text: &str, pos: usize) -> bool { .any(|keyword| starts_with_keyword_at(text, pos, keyword)) } +fn is_row_lock_clause(text: &str) -> bool { + if !starts_with_keyword(text, "FOR") { + return false; + } + let mode_start = skip_whitespace(text, "FOR".len()); + if starts_with_keyword_at(text, mode_start, "UPDATE") || starts_with_keyword_at(text, mode_start, "SHARE") { + return true; + } + if starts_with_keyword_at(text, mode_start, "NO") { + let key_start = skip_whitespace(text, mode_start + "NO".len()); + let update_start = skip_whitespace(text, key_start + "KEY".len()); + return starts_with_keyword_at(text, key_start, "KEY") && starts_with_keyword_at(text, update_start, "UPDATE"); + } + if starts_with_keyword_at(text, mode_start, "KEY") { + let share_start = skip_whitespace(text, mode_start + "KEY".len()); + return starts_with_keyword_at(text, share_start, "SHARE"); + } + false +} + fn starts_with_keyword_at(text: &str, pos: usize, keyword: &str) -> bool { let start = skip_whitespace(text, pos); let Some(candidate) = text.get(start..start + keyword.len()) else { @@ -888,6 +917,61 @@ mod tests { assert!(analysis.columns.is_empty()); } + #[test] + fn recognizes_oracle_for_update_clauses_without_treating_for_as_an_alias() { + for sql in [ + "SELECT * FROM employees FOR UPDATE", + "SELECT * FROM employees FOR UPDATE NOWAIT", + "SELECT * FROM employees FOR UPDATE SKIP LOCKED", + "SELECT * FROM employees FOR UPDATE OF salary, department_id WAIT 5", + "SELECT * FROM employees WHERE department_id = 10 ORDER BY employee_id FOR UPDATE OF salary NOWAIT", + ] { + let result = analyze_editable_query_editability(sql); + + assert!(result.editable, "{sql}"); + let analysis = result.analysis.unwrap(); + assert_eq!(analysis.table_name, "employees", "{sql}"); + assert_eq!(analysis.table_alias, None, "{sql}"); + assert!(analysis.select_star, "{sql}"); + } + + let result = analyze_editable_query_editability( + "SELECT e.employee_id, e.salary FROM employees e FOR UPDATE OF e.salary SKIP LOCKED", + ); + assert!(result.editable); + assert_eq!(result.analysis.unwrap().table_alias.as_deref(), Some("e")); + } + + #[test] + fn recognizes_postgres_row_lock_clauses_without_treating_for_as_an_alias() { + for sql in [ + "SELECT * FROM jobs FOR SHARE", + "SELECT * FROM jobs FOR NO KEY UPDATE SKIP LOCKED", + "SELECT * FROM jobs FOR KEY SHARE NOWAIT", + ] { + let result = analyze_editable_query_editability(sql); + + assert!(result.editable, "{sql}"); + let analysis = result.analysis.unwrap(); + assert_eq!(analysis.table_name, "jobs", "{sql}"); + assert_eq!(analysis.table_alias, None, "{sql}"); + } + } + + #[test] + fn keeps_explicit_read_only_and_output_for_clauses_non_editable() { + for sql in [ + "SELECT * FROM employees FOR READ ONLY", + "SELECT * FROM employees FOR FETCH ONLY", + "SELECT * FROM employees FOR JSON", + ] { + let result = analyze_editable_query_editability(sql); + + assert!(!result.editable, "{sql}"); + assert_eq!(result.reason, Some(QueryEditabilityReason::ComplexSource), "{sql}"); + } + } + #[test] fn ignores_where_subquery_sources_for_editable_target() { let result = analyze_editable_query_editability( diff --git a/packages/app-tests/sqlAnalysis.test.ts b/packages/app-tests/sqlAnalysis.test.ts index c8f88191a0..44d78a7c07 100644 --- a/packages/app-tests/sqlAnalysis.test.ts +++ b/packages/app-tests/sqlAnalysis.test.ts @@ -36,6 +36,46 @@ test("keeps the legacy analyzer API for editable SELECT queries", () => { assert.deepEqual(analysis.columns, []); }); +test("recognizes Oracle FOR UPDATE variants without treating FOR as an alias", () => { + for (const sql of [ + "SELECT * FROM employees FOR UPDATE", + "SELECT * FROM employees FOR UPDATE NOWAIT", + "SELECT * FROM employees FOR UPDATE SKIP LOCKED", + "SELECT * FROM employees FOR UPDATE OF salary, department_id WAIT 5", + "SELECT * FROM employees WHERE department_id = 10 ORDER BY employee_id FOR UPDATE OF salary NOWAIT", + ]) { + const result = analyzeEditableQueryEditability(sql); + + assert.equal(result.editable, true, sql); + assert.equal(result.analysis.tableName, "employees", sql); + assert.equal(result.analysis.tableAlias, undefined, sql); + assert.equal(result.analysis.selectStar, true, sql); + } + + const aliased = analyzeEditableQueryEditability("SELECT e.employee_id, e.salary FROM employees e FOR UPDATE OF e.salary SKIP LOCKED"); + assert.equal(aliased.editable, true); + assert.equal(aliased.analysis.tableAlias, "e"); +}); + +test("keeps explicit read-only and output FOR clauses non-editable", () => { + for (const sql of ["SELECT * FROM employees FOR READ ONLY", "SELECT * FROM employees FOR FETCH ONLY", "SELECT * FROM employees FOR JSON"]) { + const result = analyzeEditableQueryEditability(sql); + + assert.equal(result.editable, false, sql); + assert.equal(result.reason, "complex-source", sql); + } +}); + +test("recognizes PostgreSQL row-lock FOR variants", () => { + for (const sql of ["SELECT * FROM jobs FOR SHARE", "SELECT * FROM jobs FOR NO KEY UPDATE SKIP LOCKED", "SELECT * FROM jobs FOR KEY SHARE NOWAIT"]) { + const result = analyzeEditableQueryEditability(sql); + + assert.equal(result.editable, true, sql); + assert.equal(result.analysis.tableName, "jobs", sql); + assert.equal(result.analysis.tableAlias, undefined, sql); + } +}); + test("maps joined query source columns for column-level editing", () => { const result = analyzeEditableQueryEditability("select u.id as user_id, u.name, o.total from users u join orders o on o.user_id = u.id");