fix(oracle): support locked edits and OceanBase sources

This commit is contained in:
t8y2 committed 2026-07-23 23:59:03 +08:00
1 parent 3b004abb1e
commit 2bdab48d78
8 files changed
+394 -5

No files matched your search

@@ -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({
@@ -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" });
+1 -1
View File
@@ -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"]);
+5 -1
View File
@@ -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);