diff --git a/src/main/java/net/ucanaccess/util/Sql.java b/src/main/java/net/ucanaccess/util/Sql.java
new file mode 100644
index 00000000..d6a86227
--- /dev/null
+++ b/src/main/java/net/ucanaccess/util/Sql.java
@@ -0,0 +1,115 @@
+package net.ucanaccess.util;
+
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
+
+/**
+ *
+ * An immutable sql statement string created from multiple tokens
+ * in order to write inline sql statements in an easy-to-read fashion
+ * spread out over multiple lines of code.
+ *
+ *
+ *
+ * The class implements {@link CharSequence} and thus can be used as a drop-in
+ * alternative wherever API supports {@code CharSequence} rather than {@code String}.
+ *
+ *
+ * Please note that the validity of the statement is never checked,
+ * and that {@code null} or empty inputs are permitted (no run-time exceptions).
+ *
+ * The input of multiple tokens is formatted into a single String by
+ * removing leading and trailing whitespace and concatenating
+ * non-empty tokens by a single space character.
+ * Further, any trailing semicolons are removed from the resulting sql string.
+ *
+ * Example:
+ *
+ *
+ * String tblName = "table";
+ * Sql.of("SELECT COUNT(*)",
+ * "FROM", tblName,
+ * " WHERE cond1 = :cond1",
+ * " AND cond2 = :cond2");
+ *
+ *
+ * @author Markus Spann
+ * @since v5.1.0
+ */
+public final class Sql implements CharSequence {
+
+ private static final Sql EMPTY_SQL = new Sql("");
+
+ /** The internal sql string. Cannot be null. */
+ private final String str;
+
+ private Sql(String sql) {
+ str = sql;
+ }
+
+ public static Sql of(CharSequence... _tokens) {
+ return _tokens == null ? EMPTY_SQL : of(Arrays.asList(_tokens));
+ }
+
+ public static Sql of(Iterable extends CharSequence> _tokens) {
+ return _tokens == null ? EMPTY_SQL : new Sql(format(_tokens));
+ }
+
+ /**
+ * Formats an sql statement from multiple tokens.
+ * Leading and trailing whitespace is removed from each token and empty tokens ignored.
+ * The tokens are joined using a single blank character to create the sql string.
+ * Finally, any trailing semicolons are removed from the resulting sql.
+ *
+ * @param _tokens collection of tokens
+ * @return formatted sql string
+ */
+ static String format(Iterable extends CharSequence> _tokens) {
+ String sql = StreamSupport.stream(Objects.requireNonNull(_tokens).spliterator(), false)
+ .filter(Objects::nonNull)
+ .map(CharSequence::toString)
+ .map(String::trim)
+ .filter(s -> !s.isEmpty())
+ .collect(Collectors.joining(" "));
+ while (sql.endsWith(";")) {
+ sql = sql.substring(0, sql.length() - 1);
+ }
+ return sql;
+ }
+
+ @Override
+ public int length() {
+ return str.length();
+ }
+
+ @Override
+ public char charAt(int _index) {
+ return str.charAt(_index);
+ }
+
+ @Override
+ public CharSequence subSequence(int _start, int _end) {
+ return str.subSequence(_start, _end);
+ }
+
+ @Override
+ public int hashCode() {
+ return str.hashCode();
+ }
+
+ @Override
+ public boolean equals(Object _obj) {
+ if (_obj == null || getClass() != _obj.getClass()) {
+ return false;
+ }
+ return str.equals(((Sql) _obj).str);
+ }
+
+ @Override
+ public String toString() {
+ return str;
+ }
+
+}
diff --git a/src/test/java/net/ucanaccess/util/SqlTest.java b/src/test/java/net/ucanaccess/util/SqlTest.java
new file mode 100644
index 00000000..47169dbb
--- /dev/null
+++ b/src/test/java/net/ucanaccess/util/SqlTest.java
@@ -0,0 +1,69 @@
+package net.ucanaccess.util;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import net.ucanaccess.test.AbstractBaseTest;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+class SqlTest extends AbstractBaseTest {
+
+ @Test
+ void testEmptyInput() {
+ assertThat(Sql.of((CharSequence[]) null).toString()).isBlank();
+ assertThat(Sql.of((CharSequence) null).toString()).isBlank();
+ assertThat(Sql.of((Collection) null).toString()).isBlank();
+ assertThat(Sql.of(new CharSequence[0]).toString()).isBlank();
+ assertThat(Sql.of(new ArrayList<>()).toString()).isBlank();
+ assertThat(Sql.of().toString()).isBlank();
+ }
+
+ @Test
+ void testMultipleStrings() {
+ assertThat(Sql.of(List.of("A", "B", "C")))
+ .hasToString("A B C");
+ assertThat(Sql.of("A", "B", "C"))
+ .hasToString("A B C");
+ }
+
+ @Test
+ void testMultipleTokens() {
+ assertThat(Sql.of("SELECT COUNT(*) FROM table",
+ "WHERE cond1 = :cond1; "))
+ .hasToString("SELECT COUNT(*) FROM table WHERE cond1 = :cond1");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {" ", "\n", "\t", ";"})
+ @NullAndEmptySource
+ void testStrip(String str) {
+ Sql sql = Sql.of(str);
+ assertThat(sql).hasToString("");
+ }
+
+ @Test
+ void testCharSequenceMethods() {
+ Sql sql = Sql.of("SELECT * FROM table");
+ assertThat(sql).hasSize(19);
+ assertThat(sql.charAt(0)).isEqualTo('S');
+ assertThat(sql.subSequence(0, 3)).isEqualTo("SEL");
+ }
+
+ @Test
+ void testEqualsAndHashCode() {
+ Sql sql1 = Sql.of("DROP TABLE table;");
+ Sql sql2 = Sql.of(sql1);
+ assertThat(sql1).isNotNull()
+ .isEqualTo(sql2)
+ .hasSameHashCodeAs(sql2)
+ .isNotEqualTo(Sql.of());
+ assertThat(sql1.toString()).isNotBlank();
+ }
+
+}