From a338fe40b2a4c3fd64c8b9f2ba3975a3912e0b5f Mon Sep 17 00:00:00 2001 From: minleejae Date: Sat, 12 Sep 2026 02:58:43 +0900 Subject: [PATCH] Support SQL Server boolean SET options with shared statement rendering --- .../sf/jsqlparser/parser/feature/Feature.java | 2 + .../sf/jsqlparser/statement/SetStatement.java | 122 ++++++++++++-- .../sf/jsqlparser/util/TablesNamesFinder.java | 4 +- .../util/deparser/SetStatementDeParser.java | 25 +-- .../validation/feature/SqlServerVersion.java | 2 +- .../validator/SetStatementValidator.java | 3 + .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 45 ++++- src/site/sphinx/usage.rst | 7 + .../statement/SqlServerSetOptionsTest.java | 157 ++++++++++++++++++ 9 files changed, 321 insertions(+), 46 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/statement/SqlServerSetOptionsTest.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 9d21315559..3fb038721f 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -683,6 +683,8 @@ public enum Feature { * @see SetStatement */ set, + /** SQL Server SET option [, option] ON | OFF. */ + sqlServerSetOptions, /** * @see ResetStatement */ diff --git a/src/main/java/net/sf/jsqlparser/statement/SetStatement.java b/src/main/java/net/sf/jsqlparser/statement/SetStatement.java index 16878f7739..6edca1a1e7 100644 --- a/src/main/java/net/sf/jsqlparser/statement/SetStatement.java +++ b/src/main/java/net/sf/jsqlparser/statement/SetStatement.java @@ -11,18 +11,84 @@ import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.operators.relational.ExpressionList; -import net.sf.jsqlparser.statement.select.PlainSelect; import java.io.Serializable; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; public final class SetStatement implements Statement { private final List values = new ArrayList<>(); private String effectParameter; + private OnOffOptions onOffOptions; + + /** SQL Server options that share the SET option [, option] ON | OFF syntax. */ + public enum OnOffOption { + QUOTED_IDENTIFIER, CONCAT_NULL_YIELDS_NULL, CURSOR_CLOSE_ON_COMMIT, ARITHABORT, ARITHIGNORE, FMTONLY, NOCOUNT, NOEXEC, NUMERIC_ROUNDABORT, PARSEONLY, ANSI_DEFAULTS, ANSI_NULL_DFLT_OFF, ANSI_NULL_DFLT_ON, ANSI_NULLS, ANSI_PADDING, ANSI_WARNINGS, FORCEPLAN, SHOWPLAN_ALL, SHOWPLAN_TEXT, SHOWPLAN_XML, IMPLICIT_TRANSACTIONS, REMOTE_PROC_TRANSACTIONS, XACT_ABORT; + + public static OnOffOption fromName(String name) { + for (OnOffOption option : values()) { + if (option.name().equalsIgnoreCase(name)) { + return option; + } + } + return null; + } + } + + /** A group of options sharing one ON/OFF value; separate from assignment expressions. */ + public static final class OnOffOptions implements Serializable { + private final List options; + private boolean on; + + public OnOffOptions(Collection options, boolean on) { + this.options = new ArrayList<>(options); + if (this.options.isEmpty() || this.options.contains(null)) { + throw new IllegalArgumentException("At least one non-null SET option is required"); + } + this.on = on; + } + + public List getOptions() { + return options; + } + + public boolean isOn() { + return on; + } + + public void setOn(boolean on) { + this.on = on; + } + + private StringBuilder appendTo(StringBuilder builder) { + if (options.isEmpty() || options.contains(null)) { + throw new IllegalStateException("Invalid SQL Server SET option group"); + } + for (int i = 0; i < options.size(); i++) { + if (i > 0) { + builder.append(", "); + } + builder.append(options.get(i)); + } + return builder.append(on ? " ON" : " OFF"); + } + } + + public OnOffOptions getOnOffOptions() { + return onOffOptions; + } + + /** Selects SQL Server option syntax, clearing any existing assignments and scope. */ + public void setOnOffOptions(OnOffOptions onOffOptions) { + Objects.requireNonNull(onOffOptions, "onOffOptions"); + clear(); + this.onOffOptions = onOffOptions; + } public SetStatement() { // empty constructor @@ -33,6 +99,7 @@ public SetStatement(Object name, ExpressionList value) { } public void add(Object name, ExpressionList value, boolean useEqual) { + onOffOptions = null; values.add(new NameExpr(name, value, useEqual)); } @@ -103,28 +170,45 @@ public void setExpressions(int idx, ExpressionList expressions) { values.get(idx).expressions = expressions; } - private String toString(NameExpr ne) { - return ne.name + (ne.useEqual ? " = " : " ") - + PlainSelect.getStringList(ne.expressions, true, false); + /** Shares statement punctuation with deparsers while allowing expression visitors. */ + public StringBuilder appendTo(StringBuilder builder, Consumer expressionRenderer) { + builder.append("SET "); + if (onOffOptions != null) { + if (!values.isEmpty() || effectParameter != null) { + throw new IllegalStateException( + "SET options cannot be combined with assignments or scope"); + } + return onOffOptions.appendTo(builder); + } + if (effectParameter != null) { + builder.append(effectParameter).append(" "); + } + for (int i = 0; i < values.size(); i++) { + if (i > 0) { + builder.append(", "); + } + appendAssignment(builder, values.get(i), expressionRenderer); + } + return builder; } - @Override - public String toString() { - StringBuilder b = new StringBuilder("SET "); - if (effectParameter != null) { - b.append(effectParameter).append(" "); - } - boolean addComma = false; - for (NameExpr ne : values) { - if (addComma) { - b.append(", "); - } else { - addComma = true; + private static void appendAssignment(StringBuilder builder, NameExpr value, + Consumer expressionRenderer) { + builder.append(value.name).append(value.useEqual ? " = " : " "); + if (value.expressions != null) { + for (int i = 0; i < value.expressions.size(); i++) { + if (i > 0) { + builder.append(", "); + } + expressionRenderer.accept((Expression) value.expressions.get(i)); } - b.append(toString(ne)); } + } - return b.toString(); + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); } public List getKeyValuePairs() { @@ -132,6 +216,7 @@ public List getKeyValuePairs() { } public void addKeyValuePairs(Collection keyValuePairs) { + onOffOptions = null; values.addAll(keyValuePairs); } @@ -140,6 +225,7 @@ public void addKeyValuePairs(NameExpr... keyValuePairs) { } public void clear() { + onOffOptions = null; values.clear(); effectParameter = null; } diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 3c596371b5..ad3131a1dc 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -1725,7 +1725,9 @@ public void visit(Execute execute) { @Override public Void visit(SetStatement setStatement, S context) { - throwUnsupported(setStatement); + if (setStatement.getOnOffOptions() == null) { + throwUnsupported(setStatement); + } return null; } diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/SetStatementDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/SetStatementDeParser.java index 31ff0f5997..9dee3a23a8 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/SetStatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/SetStatementDeParser.java @@ -9,11 +9,9 @@ */ package net.sf.jsqlparser.util.deparser; -import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.ExpressionVisitor; import net.sf.jsqlparser.statement.SetStatement; -import java.util.List; public class SetStatementDeParser extends AbstractDeParser { @@ -27,28 +25,7 @@ public SetStatementDeParser(ExpressionVisitor expressionVisitor, @Override public void deParse(SetStatement set) { - builder.append("SET "); - if (set.getEffectParameter() != null) { - builder.append(set.getEffectParameter()).append(" "); - } - for (int i = 0; i < set.getCount(); i++) { - if (i > 0) { - builder.append(", "); - } - builder.append(set.getName(i)); - if (set.isUseEqual(i)) { - builder.append(" ="); - } - builder.append(" "); - List expressions = set.getExpressions(i); - for (int j = 0; j < expressions.size(); j++) { - if (j > 0) { - builder.append(", "); - } - expressions.get(j).accept(expressionVisitor, null); - } - } - + set.appendTo(builder, expression -> expression.accept(expressionVisitor, null)); } public ExpressionVisitor getExpressionVisitor() { 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 863fbc839c..e88370c6d9 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 @@ -83,7 +83,7 @@ public enum SqlServerVersion implements Version { Feature.executeExec, Feature.executeExecute, // https://docs.microsoft.com/en-us/sql/t-sql/language-elements/set-local-variable-transact-sql?view=sql-server-ver15 - Feature.set, + Feature.set, Feature.sqlServerSetOptions, // https://docs.microsoft.com/en-us/sql/t-sql/statements/alter-table-transact-sql?view=sql-server-ver15 Feature.alterTable, // https://docs.microsoft.com/en-us/sql/t-sql/statements/alter-sequence-transact-sql?view=sql-server-ver15 diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/SetStatementValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/SetStatementValidator.java index 9e66b47be0..ee2e7b6c3e 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/SetStatementValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/SetStatementValidator.java @@ -23,6 +23,9 @@ public class SetStatementValidator extends AbstractValidator { public void validate(SetStatement set) { for (ValidationCapability c : getCapabilities()) { validateFeature(c, Feature.set); + if (set.getOnOffOptions() != null) { + validateFeature(c, Feature.sqlServerSetOptions); + } } for (int i = 0; i < set.getCount(); i++) { validateOptionalExpressions(set.getExpressions(i)); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 1720fff54c..fc846e49a1 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -4064,7 +4064,22 @@ SessionStatement SessionStatement(): } SetStatement Set(): { - String namePart; + SetStatement set; +} +{ + + ( + LOOKAHEAD({ Dialect.SQLSERVER.name().equals(getAsString(Feature.dialect)) + && getToken(1).kind == S_IDENTIFIER + && SetStatement.OnOffOption.fromName(getToken(1).image) != null + && !"=".equals(getToken(2).image) && !".".equals(getToken(2).image) }) + set=SqlServerSetOnOffOptions() + | set=SetAssignments() + ) + { return set; } +} + +SetStatement SetAssignments(): { Object name; ExpressionList expList; boolean useEqual = false; @@ -4074,7 +4089,6 @@ SetStatement Set(): { String effectParameter = null; } { - [LOOKAHEAD(3) (tk = | tk = ) {effectParameter = tk.image; } ] ( LOOKAHEAD(2) @@ -4122,6 +4136,33 @@ SetStatement Set(): { { return set; } } +SetStatement SqlServerSetOnOffOptions(): { + SetStatement set = new SetStatement(); + List options = new ArrayList(); + SetStatement.OnOffOption option; + Token name; + boolean on; +} +{ + name= + { + option = accessEnum(SetStatement.OnOffOption.class, name.image); + options.add(option); + } + ( + "," name= + { + option = accessEnum(SetStatement.OnOffOption.class, name.image); + options.add(option); + } + )* + ( { on = true; } | { on = false; } ) + { + set.setOnOffOptions(new SetStatement.OnOffOptions(options, on)); + return set; + } +} + ResetStatement Reset(): { String name; ResetStatement reset; diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index b77dc87e4f..6cac53578a 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -788,6 +788,13 @@ operators such as ``js#>>'{a}'`` and ``js#>'{a}'`` work without surrounding spaces. Quote identifiers containing ``#``, for example ``"js#"``. Other dialects retain their existing identifier and hash-comment rules. +With ``Dialect.SQLSERVER``, ``SET NOCOUNT ON`` and grouped boolean options such as +``SET QUOTED_IDENTIFIER, ANSI_NULLS OFF`` use ``SetStatement.getOnOffOptions()``. +The ordered ``OnOffOption`` list and shared ``isOn()`` value are editable; +``setOnOffOptions()`` replaces generic assignments and their scope. Both SQL +renderers share statement punctuation while generic assignments retain expression +visitor support. Parsing a SET directive records it without changing lexer settings. + With ``Dialect.SQLSERVER``, ``PRIMARY KEY NONCLUSTERED (id)`` and ``UNIQUE CLUSTERED (id)`` store their clustering option in ``Index.getClustering()`` for both ``CREATE TABLE`` and ``ALTER TABLE``. Without that dialect, these words diff --git a/src/test/java/net/sf/jsqlparser/statement/SqlServerSetOptionsTest.java b/src/test/java/net/sf/jsqlparser/statement/SqlServerSetOptionsTest.java new file mode 100644 index 0000000000..4b7b3f62fa --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/SqlServerSetOptionsTest.java @@ -0,0 +1,157 @@ +/*- + * #%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.Arrays; +import java.util.Collections; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.statement.SetStatement.OnOffOption; +import net.sf.jsqlparser.statement.SetStatement.OnOffOptions; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.ExpressionDeParser; +import net.sf.jsqlparser.util.deparser.SetStatementDeParser; +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.validator.SetStatementValidator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.*; + +class SqlServerSetOptionsTest { + private SetStatement parse(String sql) throws JSQLParserException { + return (SetStatement) CCJSqlParserUtil.parse(sql, + p -> p.withDialect(Dialect.SQLSERVER).withUnsupportedStatements(false)); + } + + private void assertRoundTrip(SetStatement set, String sql) throws Exception { + assertEquals(sql, set.toString()); + StringBuilder buffer = new StringBuilder(); + set.accept(new StatementDeParser(buffer), null); + assertEquals(sql, buffer.toString()); + assertEquals(sql, parse(buffer.toString()).toString()); + } + + @ParameterizedTest + @EnumSource(OnOffOption.class) + void parsesBothStatesAsOptions(OnOffOption option) throws Exception { + for (boolean on : new boolean[] {true, false}) { + String sql = "SET " + option + (on ? " ON" : " OFF"); + SetStatement set = parse(sql); + assertEquals(Collections.singletonList(option), set.getOnOffOptions().getOptions()); + assertEquals(on, set.getOnOffOptions().isOn()); + assertEquals(0, set.getCount()); + assertRoundTrip(set, sql); + } + } + + @Test + void retainsGroupedOptionsAndEditsTheirSharedValue() throws Exception { + SetStatement set = parse("set quoted_identifier, /* group */ ansi_nulls on"); + assertEquals(Arrays.asList(OnOffOption.QUOTED_IDENTIFIER, OnOffOption.ANSI_NULLS), + set.getOnOffOptions().getOptions()); + set.getOnOffOptions().getOptions().set(0, OnOffOption.NOCOUNT); + set.getOnOffOptions().setOn(false); + assertRoundTrip(set, "SET NOCOUNT, ANSI_NULLS OFF"); + assertTrue(new TablesNamesFinder().getTables(set).isEmpty()); + assertEquals(3, CCJSqlParserUtil.parseStatements( + "SET NOCOUNT ON; SET XACT_ABORT OFF; SELECT 1;", + p -> p.withDialect(Dialect.SQLSERVER)).size()); + Object context = new Object(); + assertSame(context, set.accept(new StatementVisitorAdapter() { + @Override + public Object visit(SetStatement statement, S value) { + assertSame(set, statement); + return value; + } + }, context)); + } + + @ParameterizedTest + @ValueSource(strings = {"SET NOCOUNT", "SET NOCOUNT TRUE", "SET NOCOUNT 1", + "SET NOCOUNT, ON", "SET NOCOUNT,, ANSI_NULLS ON", "SET NOCOUNT, unknown ON", + "SET NOCOUNT ON OFF", "SET NOCOUNT ON, ANSI_NULLS OFF"}) + void rejectsIncompleteOrMixedOptionGroups(String sql) { + assertThrows(JSQLParserException.class, () -> parse(sql)); + } + + @Test + void retainsGenericAssignmentsAndDialectIsolation() throws Exception { + for (String sql : new String[] {"SET NOCOUNT = 1", "SET NOCOUNT.value = 1", + "SET v = 1, c = 3", "SET v = 1, 3", "SET @Flag = 1", + "SET LOCAL Time Zone 'UTC'", "SET standard_conforming_strings = on"}) { + SetStatement set = parse(sql); + assertNull(set.getOnOffOptions()); + assertEquals(CCJSqlParserUtil.parse(sql).toString(), set.toString()); + assertRoundTrip(set, set.toString()); + } + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse("SET NOCOUNT ON")); + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse("SET NOCOUNT ON", + p -> p.withDialect(Dialect.POSTGRESQL))); + SetStatement generic = (SetStatement) CCJSqlParserUtil.parse("SET NOCOUNT OFF"); + assertNull(generic.getOnOffOptions()); + assertEquals(1, generic.getCount()); + } + + @Test + void sharesPunctuationWithoutBypassingCustomExpressionDeparsers() throws Exception { + SetStatement set = parse("SET v = 1, 3, c = 5"); + StringBuilder buffer = new StringBuilder(); + ExpressionDeParser expressions = new ExpressionDeParser() { + @Override + public StringBuilder visit(LongValue value, S context) { + return getBuilder().append(value.getValue() + 10); + } + }; + expressions.setBuilder(buffer); + new SetStatementDeParser(expressions, buffer).deParse(set); + assertEquals("SET v = 11, 13, c = 15", buffer.toString()); + } + + @Test + void switchesBetweenAssignmentAndOptionForms() throws Exception { + SetStatement set = parse("SET LOCAL x = 1"); + set.setOnOffOptions(new OnOffOptions(Collections.singleton(OnOffOption.NOCOUNT), true)); + assertNull(set.getEffectParameter()); + assertRoundTrip(set, "SET NOCOUNT ON"); + set.add("x", new ExpressionList<>(new LongValue(2)), true); + assertNull(set.getOnOffOptions()); + assertRoundTrip(set, "SET x = 2"); + set.setOnOffOptions(new OnOffOptions(Collections.singleton(OnOffOption.XACT_ABORT), false)); + set.clear(); + assertNull(set.getOnOffOptions()); + assertEquals(0, set.getCount()); + assertThrows(IllegalArgumentException.class, + () -> new OnOffOptions(Collections.emptyList(), true)); + } + + @Test + void validatesSqlServerCapabilitySeparatelyFromGenericSet() throws Exception { + SetStatement set = parse("SET ANSI_NULLS, QUOTED_IDENTIFIER ON"); + SetStatementValidator validator = new SetStatementValidator(); + validator.setContext(new ValidationContext().setCapabilities( + Arrays.asList(SqlServerVersion.V2019, PostgresqlVersion.V10))); + validator.validate(set); + assertFalse(validator.getValidationErrors().containsKey(SqlServerVersion.V2019)); + ValidationTestAsserts.assertNotSupported( + validator.getValidationErrors().get(PostgresqlVersion.V10), + Feature.sqlServerSetOptions); + } +}