From c2edde0eb9aad8c8cb5b45d30a9fd96da07d769b Mon Sep 17 00:00:00 2001 From: minleejae Date: Sat, 12 Sep 2026 02:58:43 +0900 Subject: [PATCH] Support SQL Server SET IDENTITY_INSERT with a structured table target --- .../sf/jsqlparser/parser/feature/Feature.java | 2 + .../statement/SetIdentityInsertStatement.java | 59 ++++++ .../statement/StatementFeatureVisitor.java | 5 + .../statement/StatementVisitor.java | 8 + .../statement/StatementVisitorAdapter.java | 5 + .../sf/jsqlparser/util/TablesNamesFinder.java | 6 + .../util/deparser/StatementDeParser.java | 6 + .../validation/feature/SqlServerVersion.java | 2 +- .../validator/SetIdentityInsertValidator.java | 22 +++ .../validator/StatementValidator.java | 7 + .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 22 +++ src/site/sphinx/usage.rst | 11 ++ .../statement/SetIdentityInsertTest.java | 170 ++++++++++++++++++ 13 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/sf/jsqlparser/statement/SetIdentityInsertStatement.java create mode 100644 src/main/java/net/sf/jsqlparser/util/validation/validator/SetIdentityInsertValidator.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/SetIdentityInsertTest.java diff --git a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java index 9d2131555..49fdc5fc8 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -644,6 +644,8 @@ public enum Feature { * @see UseStatement */ use, + /** SQL Server session permission to insert explicit identity values into a table. */ + setIdentityInsert, /** * @see Grant */ diff --git a/src/main/java/net/sf/jsqlparser/statement/SetIdentityInsertStatement.java b/src/main/java/net/sf/jsqlparser/statement/SetIdentityInsertStatement.java new file mode 100644 index 000000000..0c8c925fb --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/SetIdentityInsertStatement.java @@ -0,0 +1,59 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement; + +import java.util.Objects; +import java.util.function.Consumer; +import net.sf.jsqlparser.schema.Table; + +/** SQL Server SET IDENTITY_INSERT [database.][schema.]table ON | OFF. */ +public final class SetIdentityInsertStatement implements Statement { + private Table table; + private boolean on; + + public SetIdentityInsertStatement(Table table, boolean on) { + setTable(table); + this.on = on; + } + + public Table getTable() { + return table; + } + + public void setTable(Table table) { + this.table = Objects.requireNonNull(table, "table"); + } + + public boolean isOn() { + return on; + } + + public void setOn(boolean on) { + this.on = on; + } + + /** Shares statement rendering while allowing deparsers to visit the existing Table AST. */ + public StringBuilder appendTo(StringBuilder builder, Consumer tableRenderer) { + builder.append("SET IDENTITY_INSERT "); + tableRenderer.accept(table); + return builder.append(on ? " ON" : " OFF"); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index 7f13f5efc..70c80a672 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -707,6 +707,11 @@ public Void visit(ResetStatement reset, S context) { return sessionOnly(); } + @Override + public Void visit(SetIdentityInsertStatement statement, S context) { + return sessionOnly(); + } + @Override public Void visit(UseStatement use, S context) { return sessionOnly(); diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index 2001533ae..c36344804 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -270,6 +270,14 @@ default void visit(Upsert upsert) { this.visit(upsert, null); } + default T visit(SetIdentityInsertStatement statement, S context) { + return null; + } + + default void visit(SetIdentityInsertStatement statement) { + visit(statement, null); + } + T visit(UseStatement use, S context); default void visit(UseStatement use) { diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index e633adebb..0f0eccdf2 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -478,6 +478,11 @@ public T visit(Upsert upsert, S context) { return null; } + @Override + public T visit(SetIdentityInsertStatement statement, S context) { + return statement.getTable().accept(fromItemVisitor, context); + } + @Override public T visit(UseStatement use, S context) { return null; diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 3c596371b..365043197 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -110,6 +110,7 @@ import net.sf.jsqlparser.statement.Statements; import net.sf.jsqlparser.statement.UnsupportedStatement; import net.sf.jsqlparser.statement.UseStatement; +import net.sf.jsqlparser.statement.SetIdentityInsertStatement; import net.sf.jsqlparser.statement.alter.Alter; import net.sf.jsqlparser.statement.alter.AlterSession; import net.sf.jsqlparser.statement.alter.AlterSystemStatement; @@ -1938,6 +1939,11 @@ public void visit(Upsert upsert) { StatementVisitor.super.visit(upsert); } + @Override + public Void visit(SetIdentityInsertStatement statement, S context) { + return statement.getTable().accept(this, context); + } + @Override public Void visit(UseStatement use, S context) { return null; diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java index d85dbddd2..cee46569f 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -54,6 +54,7 @@ import net.sf.jsqlparser.statement.Statements; import net.sf.jsqlparser.statement.UnsupportedStatement; import net.sf.jsqlparser.statement.UseStatement; +import net.sf.jsqlparser.statement.SetIdentityInsertStatement; import net.sf.jsqlparser.statement.alter.Alter; import net.sf.jsqlparser.statement.alter.AlterSession; import net.sf.jsqlparser.statement.alter.AlterSystemStatement; @@ -378,6 +379,11 @@ public StringBuilder visit(Upsert upsert, S context) { return builder; } + @Override + public StringBuilder visit(SetIdentityInsertStatement statement, S context) { + return statement.appendTo(builder, table -> table.accept(selectDeParser, context)); + } + @Override public StringBuilder visit(UseStatement use, S context) { new UseStatementDeParser(builder).deParse(use); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/SqlServerVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/SqlServerVersion.java index 863fbc839..7863341c2 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/SqlServerVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/SqlServerVersion.java @@ -108,7 +108,7 @@ public enum SqlServerVersion implements Version { Feature.commit, // special sql-server features // https://docs.microsoft.com/en-us/sql/relational-databases/xml/for-xml-sql-server?view=sql-server-ver15 Feature.selectForXmlPath, - Feature.use, Feature.allowSquareBracketQuotation, // + Feature.use, Feature.setIdentityInsert, Feature.allowSquareBracketQuotation, // Feature.pivot, Feature.unpivot, Feature.pivotXml, Feature.selectGroupByGroupingSets)); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/SetIdentityInsertValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/SetIdentityInsertValidator.java new file mode 100644 index 000000000..9bb0b4953 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/SetIdentityInsertValidator.java @@ -0,0 +1,22 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.util.validation.validator; + +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.statement.SetIdentityInsertStatement; +import net.sf.jsqlparser.util.validation.metadata.NamedObject; + +public class SetIdentityInsertValidator extends AbstractValidator { + @Override + public void validate(SetIdentityInsertStatement statement) { + validateFeatureAndName(Feature.setIdentityInsert, NamedObject.table, + statement.getTable().getFullyQualifiedName()); + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java index 282e30172..69bec7bfe 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java @@ -53,6 +53,7 @@ import net.sf.jsqlparser.statement.Statements; import net.sf.jsqlparser.statement.UnsupportedStatement; import net.sf.jsqlparser.statement.UseStatement; +import net.sf.jsqlparser.statement.SetIdentityInsertStatement; import net.sf.jsqlparser.statement.alter.Alter; import net.sf.jsqlparser.statement.alter.AlterSession; import net.sf.jsqlparser.statement.alter.AlterSystemStatement; @@ -269,6 +270,12 @@ public Void visit(Upsert upsert, S context) { return null; } + @Override + public Void visit(SetIdentityInsertStatement statement, S context) { + getValidator(SetIdentityInsertValidator.class).validate(statement); + return null; + } + @Override public Void visit(UseStatement use, S context) { getValidator(UseStatementValidator.class).validate(use); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 1720fff54..6d67790e8 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -2785,6 +2785,12 @@ Statement SingleStatement() : LOOKAHEAD({ getToken(1).kind == K_DO && Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)) }) stm = DoStatement() | + LOOKAHEAD({ Dialect.SQLSERVER.name().equals(getAsString(Feature.dialect)) + && getToken(1).kind == K_SET && getToken(2).kind == S_IDENTIFIER + && "IDENTITY_INSERT".equalsIgnoreCase(getToken(2).image) + && !"=".equals(getToken(3).image) && !".".equals(getToken(3).image) }) + stm = SetIdentityInsert() + | stm = Set() | stm = Reset() @@ -4387,6 +4393,22 @@ List ExplainStatementOptions(): } } +SetIdentityInsertStatement SetIdentityInsert(): { + Table table; + boolean on; +} +{ + AccessKeyword("IDENTITY_INSERT") table=Table() + { + requireAccessSyntax(table.getNameParts().size() <= 3, + "IDENTITY_INSERT requires a table name with at most three parts"); + } + ( { on = true; } | { on = false; } ) + { + return new SetIdentityInsertStatement(table, on); + } +} + UseStatement Use(): { String name; boolean hasSchemaKeyword = false; diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index b77dc87e4..cf08f7ac3 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -1006,6 +1006,17 @@ Parse procedure definitions one SQL Server batch at a time: a procedure consumes remaining batch, including SQL after an ``END``. Client-side ``GO`` batch splitting is not performed by this routine declaration parser. +SQL Server identity inserts +--------------------------- + +With ``Dialect.SQLSERVER``, ``SET IDENTITY_INSERT dbo.actor ON`` uses +``SetIdentityInsertStatement``. ``getTable()`` reuses the qualified ``Table`` AST; +``isOn()`` and ``setOn()`` expose the session setting. Table names may include a +database and schema, including SQL Server bracket-quoted identifiers. Table +visitors, both SQL renderers and metadata validation use this structured target. +Feature analysis reports ``MODIFIES_SESSION``; the directive itself inserts no rows. +The dedicated validation capability is ``setIdentityInsert``. + Legacy MySQL GROUP BY ordering ============================== diff --git a/src/test/java/net/sf/jsqlparser/statement/SetIdentityInsertTest.java b/src/test/java/net/sf/jsqlparser/statement/SetIdentityInsertTest.java new file mode 100644 index 000000000..b83de91b5 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/SetIdentityInsertTest.java @@ -0,0 +1,170 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.select.FromItemVisitorAdapter; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.ExpressionDeParser; +import net.sf.jsqlparser.util.deparser.SelectDeParser; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import net.sf.jsqlparser.util.validation.ValidationContext; +import net.sf.jsqlparser.util.validation.ValidationTestAsserts; +import net.sf.jsqlparser.util.validation.feature.PostgresqlVersion; +import net.sf.jsqlparser.util.validation.feature.SqlServerVersion; +import net.sf.jsqlparser.util.validation.metadata.DatabaseMetaDataValidation; +import net.sf.jsqlparser.util.validation.metadata.Named; +import net.sf.jsqlparser.util.validation.metadata.NamedObject; +import net.sf.jsqlparser.util.validation.validator.StatementValidator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.*; + +class SetIdentityInsertTest { + private SetIdentityInsertStatement parse(String sql) throws JSQLParserException { + return (SetIdentityInsertStatement) CCJSqlParserUtil.parse(sql, + p -> p.withDialect(Dialect.SQLSERVER).withUnsupportedStatements(false)); + } + + private void assertRoundTrip(SetIdentityInsertStatement set, String sql) throws Exception { + assertEquals(sql, set.toString()); + StringBuilder buffer = new StringBuilder(); + set.accept(new StatementDeParser(buffer), null); + assertEquals(sql, buffer.toString()); + SetIdentityInsertStatement reparsed = parse(buffer.toString()); + assertEquals(set.isOn(), reparsed.isOn()); + assertEquals(set.getTable().getFullyQualifiedName(), + reparsed.getTable().getFullyQualifiedName()); + } + + @ParameterizedTest + @ValueSource(strings = {"language", "country", "city", "address", "actor", "staff", + "store", "category", "film", "inventory", "customer", "rental", "payment"}) + void parsesSakilaIdentityDirectives(String table) throws Exception { + for (boolean on : new boolean[] {true, false}) { + String sql = "SET IDENTITY_INSERT " + table + (on ? " ON" : " OFF"); + SetIdentityInsertStatement set = parse(sql); + assertEquals(table, set.getTable().getName()); + assertEquals(on, set.isOn()); + assertRoundTrip(set, sql); + } + } + + @ParameterizedTest + @ValueSource(strings = {"dbo.actor", "sakila.dbo.actor", "sakila..actor", + "[sakila].[dbo].[order]", "[table with spaces]", "#staging"}) + void reusesQualifiedTableNames(String table) throws Exception { + SetIdentityInsertStatement set = parse("set identity_insert " + table + " on"); + assertEquals(table, set.getTable().getFullyQualifiedName()); + assertEquals(Collections.singleton(table), new TablesNamesFinder().getTables(set)); + assertRoundTrip(set, "SET IDENTITY_INSERT " + table + " ON"); + } + + @Test + void exposesEditableTargetAndStateWithoutClaimingDataChanges() throws Exception { + SetIdentityInsertStatement set = parse("SET IDENTITY_INSERT dbo.actor ON"); + set.getTable().setName("customer"); + set.setOn(false); + assertRoundTrip(set, "SET IDENTITY_INSERT dbo.customer OFF"); + set.setTable(new Table("replacement")); + assertRoundTrip(set, "SET IDENTITY_INSERT replacement OFF"); + assertEquals(Collections.singleton(StmtFeature.MODIFIES_SESSION), + set.getFeatures().getCertain()); + assertTrue(set.getFeatures().getUncertain().isEmpty()); + assertEquals(3, CCJSqlParserUtil.parseStatements( + "SET IDENTITY_INSERT dbo.actor ON; INSERT INTO dbo.actor VALUES (1); " + + "SET IDENTITY_INSERT dbo.actor OFF;", + p -> p.withDialect(Dialect.SQLSERVER)).size()); + } + + @Test + void traversesTheTargetWithVisitorContext() throws Exception { + SetIdentityInsertStatement set = parse("SET IDENTITY_INSERT dbo.actor ON"); + Object context = new Object(); + List
visited = new ArrayList<>(); + FromItemVisitorAdapter tables = new FromItemVisitorAdapter() { + @Override + public Void visit(Table table, S value) { + assertSame(context, value); + visited.add(table); + return null; + } + }; + set.accept(new StatementVisitorAdapter(null, null, null, tables, null, null), + context); + assertEquals(Collections.singletonList(set.getTable()), visited); + + StringBuilder buffer = new StringBuilder(); + SelectDeParser selects = new SelectDeParser() { + @Override + public StringBuilder visit(Table table, S value) { + assertSame(context, value); + return getBuilder().append("mapped_table"); + } + }; + set.accept(new StatementDeParser(new ExpressionDeParser(), selects, buffer), context); + assertEquals("SET IDENTITY_INSERT mapped_table ON", buffer.toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"SET IDENTITY_INSERT", "SET IDENTITY_INSERT ON", + "SET IDENTITY_INSERT dbo.actor", "SET IDENTITY_INSERT dbo.actor TRUE", + "SET IDENTITY_INSERT dbo.actor = ON", "SET IDENTITY_INSERT dbo.actor ON OFF", + "SET IDENTITY_INSERT server.sakila.dbo.actor ON", + "SET IDENTITY_INSERT dbo.actor AS a ON", "SET IDENTITY_INSERT @actor ON"}) + void rejectsMalformedDirectives(String sql) { + assertThrows(JSQLParserException.class, () -> parse(sql)); + } + + @Test + void preservesGenericSetAndRequiresSqlServerDialect() throws Exception { + String sql = "SET IDENTITY_INSERT dbo.actor ON"; + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql)); + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.POSTGRESQL))); + for (String generic : new String[] {"SET IDENTITY_INSERT = 1", + "SET IDENTITY_INSERT.value = 1", "SET @flag = 1"}) { + assertInstanceOf(SetStatement.class, CCJSqlParserUtil.parse(generic, + p -> p.withDialect(Dialect.SQLSERVER))); + } + } + + @Test + void validatesTheCapabilityAndExistingTargetTable() throws Exception { + SetIdentityInsertStatement set = parse("SET IDENTITY_INSERT dbo.actor OFF"); + List visited = new ArrayList<>(); + DatabaseMetaDataValidation metadata = name -> { + visited.add(name); + return true; + }; + StatementValidator validator = new StatementValidator(); + validator.setContext(new ValidationContext().setCapabilities( + Arrays.asList(SqlServerVersion.V2019, PostgresqlVersion.V10, metadata))); + set.accept(validator, null); + assertFalse(validator.getValidationErrors().containsKey(SqlServerVersion.V2019)); + assertFalse(validator.getValidationErrors().containsKey(metadata)); + ValidationTestAsserts.assertNotSupported( + validator.getValidationErrors().get(PostgresqlVersion.V10), + Feature.setIdentityInsert); + assertEquals(1, visited.size()); + assertEquals(NamedObject.table, visited.get(0).getNamedObject()); + assertEquals("dbo.actor", visited.get(0).getFqn()); + } +}