factories) {
+ ChainingConnectionFactory(
+ Iterable extends Quidem.ConnectionFactory> factories) {
this.factories = ImmutableList.copyOf(factories);
}
- @Override public Connection connect(String name, boolean reference)
+ @Override public @Nullable Connection connect(String name, boolean reference)
throws Exception {
for (Quidem.ConnectionFactory factory : factories) {
Connection c = factory.connect(name, reference);
diff --git a/src/main/java/net/hydromatic/quidem/ConnectionFactories.java b/src/main/java/net/hydromatic/quidem/ConnectionFactories.java
new file mode 100644
index 0000000..7e6e95a
--- /dev/null
+++ b/src/main/java/net/hydromatic/quidem/ConnectionFactories.java
@@ -0,0 +1,59 @@
+/*
+ * Licensed to Julian Hyde under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package net.hydromatic.quidem;
+
+import com.google.common.collect.ImmutableList;
+
+/** Utilities for {@link net.hydromatic.quidem.Quidem.ConnectionFactory}. */
+public abstract class ConnectionFactories {
+ private ConnectionFactories() {
+ }
+
+ /** Creates a connection factory that always throws. */
+ public static Quidem.ConnectionFactory unsupported() {
+ return new UnsupportedConnectionFactory();
+ }
+
+ /** Creates a connection factory that uses simple JDBC credentials. */
+ public static Quidem.ConnectionFactory simple(String name, String url,
+ String user, String password) {
+ return new SimpleConnectionFactory(name, url, user, password);
+ }
+
+ /** Creates a connection factory that tries each of a list of factories in
+ * turn. */
+ public static Quidem.ConnectionFactory chain(
+ Iterable extends Quidem.ConnectionFactory> factories) {
+ return new ChainingConnectionFactory(
+ ImmutableList.copyOf(factories));
+ }
+
+ /** Creates a connection factory that tries each of an array of factories in
+ * turn. */
+ public static Quidem.ConnectionFactory chain(
+ Quidem.ConnectionFactory... connectionFactories) {
+ return chain(ImmutableList.copyOf(connectionFactories));
+ }
+
+ /** Creates a connection factory that returns {@code null} for any requested
+ * database. */
+ public static Quidem.ConnectionFactory empty() {
+ return new ChainingConnectionFactory(ImmutableList.of());
+ }
+}
+
+// End ConnectionFactories.java
diff --git a/src/main/java/net/hydromatic/quidem/Launcher.java b/src/main/java/net/hydromatic/quidem/Launcher.java
index 3ad0989..d0ecc04 100644
--- a/src/main/java/net/hydromatic/quidem/Launcher.java
+++ b/src/main/java/net/hydromatic/quidem/Launcher.java
@@ -117,7 +117,7 @@ public Quidem parse() throws ParseException {
final String url = args.get(i + 2);
final String user = args.get(i + 3);
final String password = args.get(i + 4);
- factories.add(new SimpleConnectionFactory(name, url, user, password));
+ factories.add(ConnectionFactories.simple(name, url, user, password));
i += 5;
continue;
}
@@ -201,9 +201,9 @@ public Quidem parse() throws ParseException {
throw new RuntimeException("Error opening output " + outFile, e);
}
- factories.add(new UnsupportedConnectionFactory());
- final ChainingConnectionFactory connectionFactory =
- new ChainingConnectionFactory(factories);
+ factories.add(ConnectionFactories.unsupported());
+ final Quidem.ConnectionFactory connectionFactory =
+ ConnectionFactories.chain(factories);
final ChainingCommandHandler commandHandler =
new ChainingCommandHandler(commandHandlers);
diff --git a/src/main/java/net/hydromatic/quidem/Quidem.java b/src/main/java/net/hydromatic/quidem/Quidem.java
index c5ae937..dcb3efc 100644
--- a/src/main/java/net/hydromatic/quidem/Quidem.java
+++ b/src/main/java/net/hydromatic/quidem/Quidem.java
@@ -71,7 +71,7 @@ public class Quidem {
/** The empty environment. Returns null for all database names. */
public static final ConnectionFactory EMPTY_CONNECTION_FACTORY =
- new ChainingConnectionFactory(ImmutableList.of());
+ ConnectionFactories.empty();
/** A command handler that defines no commands. */
public static final CommandHandler EMPTY_COMMAND_HANDLER =
@@ -1236,7 +1236,9 @@ public void execute(Context x, boolean execute) throws Exception {
*
* It is kind of a directory service.
*
- *
Caller must close the connection. */
+ *
Caller must close the connection.
+ *
+ * @see ConnectionFactories */
public interface ConnectionFactory {
/** Creates a connection to the named database or reference database.
*
@@ -1247,7 +1249,8 @@ public interface ConnectionFactory {
* @param reference Whether we require a real connection or a reference
* connection
*/
- Connection connect(String name, boolean reference) throws Exception;
+ @Nullable Connection connect(String name, boolean reference)
+ throws Exception;
}
/** Property whose value may be set. */
diff --git a/src/main/java/net/hydromatic/quidem/SimpleConnectionFactory.java b/src/main/java/net/hydromatic/quidem/SimpleConnectionFactory.java
index 46c29f2..3540837 100644
--- a/src/main/java/net/hydromatic/quidem/SimpleConnectionFactory.java
+++ b/src/main/java/net/hydromatic/quidem/SimpleConnectionFactory.java
@@ -16,6 +16,8 @@
*/
package net.hydromatic.quidem;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
import java.sql.Connection;
import java.sql.DriverManager;
@@ -35,7 +37,7 @@ class SimpleConnectionFactory implements Quidem.ConnectionFactory {
this.password = password;
}
- @Override public Connection connect(String name, boolean reference)
+ @Override public @Nullable Connection connect(String name, boolean reference)
throws Exception {
if (!reference && name.equals(this.name)) {
return DriverManager.getConnection(url, user, password);
diff --git a/src/main/java/net/hydromatic/quidem/UnsupportedConnectionFactory.java b/src/main/java/net/hydromatic/quidem/UnsupportedConnectionFactory.java
index ff01a5c..82488b6 100644
--- a/src/main/java/net/hydromatic/quidem/UnsupportedConnectionFactory.java
+++ b/src/main/java/net/hydromatic/quidem/UnsupportedConnectionFactory.java
@@ -16,13 +16,15 @@
*/
package net.hydromatic.quidem;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
import java.sql.Connection;
/** Connection factory that says all databases are unknown,
* and returns null when asked for a reference connection. */
class UnsupportedConnectionFactory
implements Quidem.ConnectionFactory {
- public Connection connect(String name, boolean reference) {
+ public @Nullable Connection connect(String name, boolean reference) {
if (reference) {
return null;
}
diff --git a/src/main/java/net/hydromatic/quidem/record/Config.java b/src/main/java/net/hydromatic/quidem/record/Config.java
new file mode 100644
index 0000000..c2bf4a1
--- /dev/null
+++ b/src/main/java/net/hydromatic/quidem/record/Config.java
@@ -0,0 +1,33 @@
+/*
+ * Licensed to Julian Hyde under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package net.hydromatic.quidem.record;
+
+import net.hydromatic.quidem.Quidem;
+
+import java.io.File;
+
+/** Configuration for recordings.
+ *
+ *
Created via {@link Recorders#config()}.
+ */
+public interface Config {
+ Config withFile(File file);
+ Config withMode(Mode mode);
+ Config withConnectionFactory(Quidem.ConnectionFactory connectionFactory);
+}
+
+// End Config.java
diff --git a/src/main/java/net/hydromatic/quidem/record/JdbcUtils.java b/src/main/java/net/hydromatic/quidem/record/JdbcUtils.java
new file mode 100644
index 0000000..279a700
--- /dev/null
+++ b/src/main/java/net/hydromatic/quidem/record/JdbcUtils.java
@@ -0,0 +1,1193 @@
+/*
+ * Licensed to Julian Hyde under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package net.hydromatic.quidem.record;
+
+import com.google.common.collect.BiMap;
+import com.google.common.collect.ImmutableBiMap;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Reader;
+import java.io.StringReader;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.math.BigDecimal;
+import java.net.URL;
+import java.sql.Array;
+import java.sql.Blob;
+import java.sql.Clob;
+import java.sql.Date;
+import java.sql.NClob;
+import java.sql.Ref;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.RowId;
+import java.sql.SQLException;
+import java.sql.SQLWarning;
+import java.sql.SQLXML;
+import java.sql.Statement;
+import java.sql.Time;
+import java.sql.Timestamp;
+import java.sql.Types;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Calendar;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import static java.util.Arrays.fill;
+
+/** JDBC utilities. */
+public abstract class JdbcUtils {
+
+ private static final ThreadLocal DATE_FORMAT =
+ ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
+ private static final ThreadLocal TIME_FORMAT =
+ ThreadLocal.withInitial(() -> new SimpleDateFormat("hh:mm:ss"));
+ private static final ThreadLocal TIMESTAMP_FORMAT =
+ ThreadLocal.withInitial(() ->
+ new SimpleDateFormat("yyyy-MM-dd hh:mm:ss"));
+
+ // utility class
+ private JdbcUtils() {
+ }
+
+ /** Names and values of fields in {@link Types}. */
+ private static final BiMap JDBC_TYPES;
+
+ static {
+ final ImmutableBiMap.Builder builder =
+ ImmutableBiMap.builder();
+ for (Field field : Types.class.getFields()) {
+ if ((field.getModifiers() & Modifier.STATIC) != 0
+ && field.getType() == int.class) {
+ try {
+ builder.put(field.getName(), field.getInt(null));
+ } catch (IllegalAccessException e) {
+ // ignore field
+ }
+ }
+ }
+ JDBC_TYPES = builder.build();
+ }
+
+ static String typeString(int type) {
+ final String typeName = JDBC_TYPES.inverse().get(type);
+ if (typeName != null) {
+ return typeName;
+ }
+ return "type" + type;
+ }
+
+ static int stringType(String typeName) {
+ final Integer type = JDBC_TYPES.get(typeName);
+ if (type != null) {
+ return type;
+ }
+ return Types.VARCHAR;
+ }
+
+ /** Writes a ResultSet to a builder.
+ *
+ * Representation is like the following:
+ *
+ *
+ * dname:VARCHAR, c:INTEGER
+ * SALES, 3
+ * MARKETING, 4
+ *
+ */
+ public static void write(StringBuilder b, ResultSet r) throws SQLException {
+ final ResultSetMetaData metaData = r.getMetaData();
+ final int columnCount = metaData.getColumnCount();
+ for (int i = 0; i < columnCount; i++) {
+ if (i > 0) {
+ b.append(",");
+ }
+ b.append(metaData.getColumnName(i + 1));
+ b.append(':');
+ b.append(typeString(metaData.getColumnType(i + 1)));
+ }
+ b.append('\n');
+ while (r.next()) {
+ for (int i = 0; i < columnCount; i++) {
+ if (i > 0) {
+ b.append(",");
+ }
+ final String s = r.getString(i + 1);
+ if (s != null) {
+ // We write NULL as the empty string. This works well for types like
+ // INTEGER, DATE, TIMESTAMP, BOOLEAN. For VARCHAR, we represent the
+ // empty string as ''.
+ if (s.isEmpty()) {
+ b.append("''");
+ } else {
+ b.append(s);
+ }
+ }
+ }
+ b.append('\n');
+ }
+ }
+
+ /** Parses a line containing comma-separated values into an array of
+ * strings.
+ *
+ * Assumes that the array has exactly the right number of slots.
+ * Puts nulls into slots where the string is empty or there are not enough
+ * commas.
+ *
+ *
If field values start with a single-quote, reads until it finds a
+ * closing single-quote. Commas inside single-quotes are part of the value.
+ * Single-quotes that are part of the value must be doubled. */
+ public static void parse(String[] fields, String s) {
+ int f = 0;
+ for (int i = 0;; i++) {
+ if (i >= s.length()) {
+ // Reached end of line.
+ break;
+ }
+ final char c = s.charAt(i);
+ if (c == ',') {
+ fields[f++] = null;
+ continue;
+ }
+ if (c == '\'') {
+ // Looking at a single-quoted string. Scan until we see the
+ // closing single-quote. Commas are not special.
+ ++i;
+ final int start = i;
+ boolean escape = false;
+ for (;;) {
+ if (i >= s.length()) {
+ throw new IllegalArgumentException("missing \"'\"");
+ }
+ final char c2 = s.charAt(i);
+ ++i;
+ if (c2 == '\'') {
+ if (i >= s.length()) {
+ // Closing single-quote at end of line.
+ String field = s.substring(start, i - 1);
+ if (escape) {
+ field = field.replace("''", "'");
+ }
+ fields[f++] = field;
+ break;
+ }
+ final char c3 = s.charAt(i);
+ if (c3 == '\'') {
+ escape = true;
+ ++i;
+ if (i >= s.length()) {
+ throw new IllegalArgumentException(
+ "missing \"'\" following escaped \"'\"");
+ }
+ } else if (c3 == ',') {
+ // Closing single-quote occurs before comma
+ String field = s.substring(start, i - 1);
+ if (escape) {
+ field = field.replace("''", "'");
+ }
+ fields[f++] = field;
+ break;
+ } else {
+ throw new IllegalArgumentException(
+ "quoted string must be followed by comma or line ending");
+ }
+ }
+ }
+ } else {
+ final int start = i;
+ for (;;) {
+ ++i;
+ if (i >= s.length()) {
+ // Field terminated by end of line
+ fields[f++] = s.substring(start, i);
+ break;
+ }
+ final char c2 = s.charAt(i);
+ if (c2 == ',') {
+ // Field terminated by comma
+ fields[f++] = s.substring(start, i);
+ break;
+ }
+ }
+ }
+ }
+ // Reached end of line. Any remaining fields are null.
+ while (f < fields.length) {
+ fields[f++] = null;
+ }
+ }
+
+ static void read(String s, Consumer consumer) throws IOException {
+ final BufferedReader bufferedReader =
+ new BufferedReader(new StringReader(s));
+ final List names = new ArrayList<>();
+ final List typeNames = new ArrayList<>();
+ String line = bufferedReader.readLine();
+ if (line == null) {
+ return; // no header row
+ }
+ final String[] headers = line.split(",");
+ for (String header : headers) {
+ int colon = header.indexOf(':');
+ if (colon < 0) {
+ names.add(header);
+ typeNames.add("VARCHAR");
+ } else {
+ names.add(header.substring(0, colon));
+ typeNames.add(header.substring(colon + 1));
+ }
+ }
+ List lines = new ArrayList<>();
+ for (;;) {
+ line = bufferedReader.readLine();
+ if (line == null) {
+ break;
+ }
+ lines.add(line);
+ }
+ final ResultSet resultSet =
+ new NullResultSet() {
+ private boolean wasNull;
+ int line = -1;
+ final String[] fields = new String[names.size()];
+
+ @Override public ResultSetMetaData getMetaData() {
+ return new NullResultSetMetaData() {
+ @Override public int getColumnCount() {
+ return names.size();
+ }
+
+ @Override public String getColumnName(int column) {
+ return names.get(column - 1);
+ }
+
+ @Override public String getColumnTypeName(int column) {
+ return typeNames.get(column - 1);
+ }
+
+ @Override public int getColumnType(int column) {
+ final String typeName = getColumnTypeName(column);
+ return stringType(typeName);
+ }
+ };
+ }
+
+ @Override public boolean next() {
+ ++line;
+ if (line >= lines.size()) {
+ fill(fields, null);
+ return false;
+ }
+
+ // Populate fields by parsing line
+ parse(fields, lines.get(line));
+ return true;
+ }
+
+ @Override public boolean wasNull() {
+ return wasNull;
+ }
+
+ @Override public String getString(int columnIndex) {
+ final String s = fields[columnIndex - 1];
+ wasNull = s == null;
+ return s;
+ }
+
+ @Override public boolean getBoolean(int columnIndex) {
+ final String s = getString(columnIndex);
+ // no need to check for null; parseBoolean(null) returns false
+ return Boolean.parseBoolean(s);
+ }
+
+ @Override public short getShort(int columnIndex) {
+ final String s = getString(columnIndex);
+ return s == null ? 0 : Short.parseShort(s);
+ }
+
+ @Override public int getInt(int columnIndex) {
+ final String s = getString(columnIndex);
+ return s == null ? 0 : Integer.parseInt(s);
+ }
+
+ @Override public long getLong(int columnIndex) {
+ final String s = getString(columnIndex);
+ return s == null ? 0 : Long.parseLong(s);
+ }
+
+ @Override public double getDouble(int columnIndex) {
+ final String s = getString(columnIndex);
+ return s == null ? 0 : Double.parseDouble(s);
+ }
+
+ @Override public float getFloat(int columnIndex) {
+ final String s = getString(columnIndex);
+ return s == null ? 0 : Float.parseFloat(s);
+ }
+
+ @Override public BigDecimal getBigDecimal(int columnIndex) {
+ final String s = getString(columnIndex);
+ return s == null ? null : new BigDecimal(s);
+ }
+
+ @Override public Date getDate(int columnIndex) {
+ final String s = getString(columnIndex);
+ if (s == null) {
+ return null;
+ }
+ try {
+ return new Date(DATE_FORMAT.get().parse(s).getTime());
+ } catch (ParseException e) {
+ throw new IllegalArgumentException(
+ String.format("invalid date '%s'", s));
+ }
+ }
+
+ @Override public Time getTime(int columnIndex) {
+ final String s = getString(columnIndex);
+ if (s == null) {
+ return null;
+ }
+ try {
+ return new Time(TIME_FORMAT.get().parse(s).getTime());
+ } catch (ParseException e) {
+ throw new IllegalArgumentException(
+ String.format("invalid time '%s'", s));
+ }
+ }
+
+ @Override public Timestamp getTimestamp(int columnIndex) {
+ final String s = getString(columnIndex);
+ if (s == null) {
+ return null;
+ }
+ try {
+ return new Timestamp(TIMESTAMP_FORMAT.get().parse(s).getTime());
+ } catch (ParseException e) {
+ throw new IllegalArgumentException(
+ String.format("invalid timestamp '%s'", s));
+ }
+ }
+ };
+ consumer.accept(resultSet);
+ }
+
+ /** Implementation of {@link java.sql.ResultSet} that implements every method
+ * but mostly does nothing. Use it as a base class. */
+ private static class NullResultSet implements ResultSet {
+ @Override public boolean next() {
+ return false;
+ }
+
+ @Override public void close() {
+ }
+
+ @Override public boolean wasNull() {
+ return false;
+ }
+
+ @Override public String getString(int columnIndex) {
+ return "";
+ }
+
+ @Override public boolean getBoolean(int columnIndex) {
+ return false;
+ }
+
+ @Override public byte getByte(int columnIndex) {
+ return 0;
+ }
+
+ @Override public short getShort(int columnIndex) {
+ return 0;
+ }
+
+ @Override public int getInt(int columnIndex) {
+ return 0;
+ }
+
+ @Override public long getLong(int columnIndex) {
+ return 0;
+ }
+
+ @Override public float getFloat(int columnIndex) {
+ return 0;
+ }
+
+ @Override public double getDouble(int columnIndex) {
+ return 0;
+ }
+
+ @Override public BigDecimal getBigDecimal(int columnIndex, int scale) {
+ return null;
+ }
+
+ @Override public byte[] getBytes(int columnIndex) {
+ return new byte[0];
+ }
+
+ @Override public Date getDate(int columnIndex) {
+ return null;
+ }
+
+ @Override public Time getTime(int columnIndex) {
+ return null;
+ }
+
+ @Override public Timestamp getTimestamp(int columnIndex) {
+ return null;
+ }
+
+ @Override public InputStream getAsciiStream(int columnIndex) {
+ return null;
+ }
+
+ @Override public InputStream getUnicodeStream(int columnIndex) {
+ return null;
+ }
+
+ @Override public InputStream getBinaryStream(int columnIndex) {
+ return null;
+ }
+
+ @Override public String getString(String columnLabel) {
+ return "";
+ }
+
+ @Override public boolean getBoolean(String columnLabel) {
+ return false;
+ }
+
+ @Override public byte getByte(String columnLabel) {
+ return 0;
+ }
+
+ @Override public short getShort(String columnLabel) {
+ return 0;
+ }
+
+ @Override public int getInt(String columnLabel) {
+ return 0;
+ }
+
+ @Override public long getLong(String columnLabel) {
+ return 0;
+ }
+
+ @Override public float getFloat(String columnLabel) {
+ return 0;
+ }
+
+ @Override public double getDouble(String columnLabel) {
+ return 0;
+ }
+
+ @Override public BigDecimal getBigDecimal(String columnLabel, int scale) {
+ return null;
+ }
+
+ @Override public byte[] getBytes(String columnLabel) {
+ return new byte[0];
+ }
+
+ @Override public Date getDate(String columnLabel) {
+ return null;
+ }
+
+ @Override public Time getTime(String columnLabel) {
+ return null;
+ }
+
+ @Override public Timestamp getTimestamp(String columnLabel) {
+ return null;
+ }
+
+ @Override public InputStream getAsciiStream(String columnLabel) {
+ return null;
+ }
+
+ @Override public InputStream getUnicodeStream(String columnLabel) {
+ return null;
+ }
+
+ @Override public InputStream getBinaryStream(String columnLabel) {
+ return null;
+ }
+
+ @Override public SQLWarning getWarnings() {
+ return null;
+ }
+
+ @Override public void clearWarnings() {
+ }
+
+ @Override public String getCursorName() {
+ return "";
+ }
+
+ @Override public ResultSetMetaData getMetaData() {
+ return null;
+ }
+
+ @Override public Object getObject(int columnIndex) {
+ return null;
+ }
+
+ @Override public Object getObject(String columnLabel) {
+ return null;
+ }
+
+ @Override public int findColumn(String columnLabel) {
+ return 0;
+ }
+
+ @Override public Reader getCharacterStream(int columnIndex) {
+ return null;
+ }
+
+ @Override public Reader getCharacterStream(String columnLabel) {
+ return null;
+ }
+
+ @Override public BigDecimal getBigDecimal(int columnIndex) {
+ return null;
+ }
+
+ @Override public BigDecimal getBigDecimal(String columnLabel) {
+ return null;
+ }
+
+ @Override public boolean isBeforeFirst() {
+ return false;
+ }
+
+ @Override public boolean isAfterLast() {
+ return false;
+ }
+
+ @Override public boolean isFirst() {
+ return false;
+ }
+
+ @Override public boolean isLast() {
+ return false;
+ }
+
+ @Override public void beforeFirst() {
+ }
+
+ @Override public void afterLast() {
+ }
+
+ @Override public boolean first() {
+ return false;
+ }
+
+ @Override public boolean last() {
+ return false;
+ }
+
+ @Override public int getRow() {
+ return 0;
+ }
+
+ @Override public boolean absolute(int row) {
+ return false;
+ }
+
+ @Override public boolean relative(int rows) {
+ return false;
+ }
+
+ @Override public boolean previous() {
+ return false;
+ }
+
+ @Override public void setFetchDirection(int direction) {
+ }
+
+ @Override public int getFetchDirection() {
+ return FETCH_FORWARD;
+ }
+
+ @Override public void setFetchSize(int rows) {
+ }
+
+ @Override public int getFetchSize() {
+ return 0;
+ }
+
+ @Override public int getType() {
+ return TYPE_FORWARD_ONLY;
+ }
+
+ @Override public int getConcurrency() {
+ return CONCUR_READ_ONLY;
+ }
+
+ @Override public boolean rowUpdated() {
+ return false;
+ }
+
+ @Override public boolean rowInserted() {
+ return false;
+ }
+
+ @Override public boolean rowDeleted() {
+ return false;
+ }
+
+ @Override public void updateNull(int columnIndex) {
+ }
+
+ @Override public void updateBoolean(int columnIndex, boolean x) {
+ }
+
+ @Override public void updateByte(int columnIndex, byte x) {
+ }
+
+ @Override public void updateShort(int columnIndex, short x) {
+ }
+
+ @Override public void updateInt(int columnIndex, int x) {
+ }
+
+ @Override public void updateLong(int columnIndex, long x) {
+ }
+
+ @Override public void updateFloat(int columnIndex, float x) {
+ }
+
+ @Override public void updateDouble(int columnIndex, double x) {
+ }
+
+ @Override public void updateBigDecimal(int columnIndex, BigDecimal x) {
+ }
+
+ @Override public void updateString(int columnIndex, String x) {
+ }
+
+ @Override public void updateBytes(int columnIndex, byte[] x) {
+ }
+
+ @Override public void updateDate(int columnIndex, Date x) {
+ }
+
+ @Override public void updateTime(int columnIndex, Time x) {
+ }
+
+ @Override public void updateTimestamp(int columnIndex, Timestamp x) {
+ }
+
+ @Override public void updateAsciiStream(int columnIndex, InputStream x,
+ int length) {
+ }
+
+ @Override public void updateBinaryStream(int columnIndex, InputStream x,
+ int length) {
+ }
+
+ @Override public void updateCharacterStream(int columnIndex, Reader x,
+ int length) {
+ }
+
+ @Override public void updateObject(int columnIndex, Object x,
+ int scaleOrLength) {
+ }
+
+ @Override public void updateObject(int columnIndex, Object x) {
+ }
+
+ @Override public void updateNull(String columnLabel) {
+ }
+
+ @Override public void updateBoolean(String columnLabel, boolean x) {
+ }
+
+ @Override public void updateByte(String columnLabel, byte x) {
+ }
+
+ @Override public void updateShort(String columnLabel, short x) {
+ }
+
+ @Override public void updateInt(String columnLabel, int x) {
+ }
+
+ @Override public void updateLong(String columnLabel, long x) {
+ }
+
+ @Override public void updateFloat(String columnLabel, float x) {
+ }
+
+ @Override public void updateDouble(String columnLabel, double x) {
+ }
+
+ @Override public void updateBigDecimal(String columnLabel, BigDecimal x) {
+ }
+
+ @Override public void updateString(String columnLabel, String x) {
+ }
+
+ @Override public void updateBytes(String columnLabel, byte[] x) {
+ }
+
+ @Override public void updateDate(String columnLabel, Date x) {
+ }
+
+ @Override public void updateTime(String columnLabel, Time x) {
+ }
+
+ @Override public void updateTimestamp(String columnLabel, Timestamp x) {
+ }
+
+ @Override public void updateAsciiStream(String columnLabel, InputStream x,
+ int length) {
+ }
+
+ @Override public void updateBinaryStream(String columnLabel, InputStream x,
+ int length) {
+ }
+
+ @Override public void updateCharacterStream(String columnLabel,
+ Reader reader, int length) {
+ }
+
+ @Override public void updateObject(String columnLabel, Object x,
+ int scaleOrLength) {
+ }
+
+ @Override public void updateObject(String columnLabel, Object x) {
+ }
+
+ @Override public void insertRow() {
+ }
+
+ @Override public void updateRow() {
+ }
+
+ @Override public void deleteRow() {
+ }
+
+ @Override public void refreshRow() {
+ }
+
+ @Override public void cancelRowUpdates() {
+ }
+
+ @Override public void moveToInsertRow() {
+ }
+
+ @Override public void moveToCurrentRow() {
+ }
+
+ @Override public Statement getStatement() {
+ return null;
+ }
+
+ @Override public Object getObject(int columnIndex,
+ Map> map) {
+ return null;
+ }
+
+ @Override public Ref getRef(int columnIndex) {
+ return null;
+ }
+
+ @Override public Blob getBlob(int columnIndex) {
+ return null;
+ }
+
+ @Override public Clob getClob(int columnIndex) {
+ return null;
+ }
+
+ @Override public Array getArray(int columnIndex) {
+ return null;
+ }
+
+ @Override public Object getObject(String columnLabel,
+ Map> map) {
+ return null;
+ }
+
+ @Override public Ref getRef(String columnLabel) {
+ return null;
+ }
+
+ @Override public Blob getBlob(String columnLabel) {
+ return null;
+ }
+
+ @Override public Clob getClob(String columnLabel) {
+ return null;
+ }
+
+ @Override public Array getArray(String columnLabel) {
+ return null;
+ }
+
+ @Override public Date getDate(int columnIndex, Calendar cal) {
+ return null;
+ }
+
+ @Override public Date getDate(String columnLabel, Calendar cal) {
+ return null;
+ }
+
+ @Override public Time getTime(int columnIndex, Calendar cal) {
+ return null;
+ }
+
+ @Override public Time getTime(String columnLabel, Calendar cal) {
+ return null;
+ }
+
+ @Override public Timestamp getTimestamp(int columnIndex, Calendar cal) {
+ return null;
+ }
+
+ @Override public Timestamp getTimestamp(String columnLabel, Calendar cal) {
+ return null;
+ }
+
+ @Override public URL getURL(int columnIndex) {
+ return null;
+ }
+
+ @Override public URL getURL(String columnLabel) {
+ return null;
+ }
+
+ @Override public void updateRef(int columnIndex, Ref x) {
+ }
+
+ @Override public void updateRef(String columnLabel, Ref x) {
+ }
+
+ @Override public void updateBlob(int columnIndex, Blob x) {
+ }
+
+ @Override public void updateBlob(String columnLabel, Blob x) {
+ }
+
+ @Override public void updateClob(int columnIndex, Clob x) {
+ }
+
+ @Override public void updateClob(String columnLabel, Clob x) {
+ }
+
+ @Override public void updateArray(int columnIndex, Array x) {
+ }
+
+ @Override public void updateArray(String columnLabel, Array x) {
+ }
+
+ @Override public RowId getRowId(int columnIndex) {
+ return null;
+ }
+
+ @Override public RowId getRowId(String columnLabel) {
+ return null;
+ }
+
+ @Override public void updateRowId(int columnIndex, RowId x) {
+ }
+
+ @Override public void updateRowId(String columnLabel, RowId x) {
+ }
+
+ @Override public int getHoldability() {
+ return CLOSE_CURSORS_AT_COMMIT;
+ }
+
+ @Override public boolean isClosed() {
+ return false;
+ }
+
+ @Override public void updateNString(int columnIndex, String nString) {
+ }
+
+ @Override public void updateNString(String columnLabel, String nString) {
+ }
+
+ @Override public void updateNClob(int columnIndex, NClob nClob) {
+ }
+
+ @Override public void updateNClob(String columnLabel, NClob nClob) {
+ }
+
+ @Override public NClob getNClob(int columnIndex) {
+ return null;
+ }
+
+ @Override public NClob getNClob(String columnLabel) {
+ return null;
+ }
+
+ @Override public SQLXML getSQLXML(int columnIndex) {
+ return null;
+ }
+
+ @Override public SQLXML getSQLXML(String columnLabel) {
+ return null;
+ }
+
+ @Override public void updateSQLXML(int columnIndex, SQLXML xmlObject) {
+ }
+
+ @Override public void updateSQLXML(String columnLabel, SQLXML xmlObject) {
+ }
+
+ @Override public String getNString(int columnIndex) {
+ return "";
+ }
+
+ @Override public String getNString(String columnLabel) {
+ return "";
+ }
+
+ @Override public Reader getNCharacterStream(int columnIndex) {
+ return null;
+ }
+
+ @Override public Reader getNCharacterStream(String columnLabel) {
+ return null;
+ }
+
+ @Override public void updateNCharacterStream(int columnIndex, Reader x,
+ long length) {
+ }
+
+ @Override public void updateNCharacterStream(String columnLabel,
+ Reader reader, long length) {
+ }
+
+ @Override public void updateAsciiStream(int columnIndex, InputStream x,
+ long length) {
+ }
+
+ @Override public void updateBinaryStream(int columnIndex, InputStream x,
+ long length) {
+ }
+
+ @Override public void updateCharacterStream(int columnIndex, Reader x,
+ long length) {
+ }
+
+ @Override public void updateAsciiStream(String columnLabel, InputStream x,
+ long length) {
+ }
+
+ @Override public void updateBinaryStream(String columnLabel, InputStream x,
+ long length) {
+ }
+
+ @Override public void updateCharacterStream(String columnLabel,
+ Reader reader, long length) {
+ }
+
+ @Override public void updateBlob(int columnIndex, InputStream inputStream,
+ long length) {
+ }
+
+ @Override public void updateBlob(String columnLabel,
+ InputStream inputStream, long length) {
+ }
+
+ @Override public void updateClob(int columnIndex, Reader reader,
+ long length) {
+ }
+
+ @Override public void updateClob(String columnLabel, Reader reader,
+ long length) {
+ }
+
+ @Override public void updateNClob(int columnIndex, Reader reader,
+ long length) {
+ }
+
+ @Override public void updateNClob(String columnLabel, Reader reader,
+ long length) {
+ }
+
+ @Override public void updateNCharacterStream(int columnIndex, Reader x) {
+ }
+
+ @Override public void updateNCharacterStream(String columnLabel,
+ Reader reader) {
+ }
+
+ @Override public void updateAsciiStream(int columnIndex, InputStream x) {
+ }
+
+ @Override public void updateBinaryStream(int columnIndex, InputStream x) {
+ }
+
+ @Override public void updateCharacterStream(int columnIndex, Reader x) {
+ }
+
+ @Override public void updateAsciiStream(String columnLabel, InputStream x) {
+ }
+
+ @Override public void updateBinaryStream(String columnLabel,
+ InputStream x) {
+ }
+
+ @Override public void updateCharacterStream(String columnLabel,
+ Reader reader) {
+ }
+
+ @Override public void updateBlob(int columnIndex, InputStream inputStream) {
+ }
+
+ @Override public void updateBlob(String columnLabel,
+ InputStream inputStream) {
+ }
+
+ @Override public void updateClob(int columnIndex, Reader reader) {
+ }
+
+ @Override public void updateClob(String columnLabel, Reader reader) {
+ }
+
+ @Override public void updateNClob(int columnIndex, Reader reader) {
+ }
+
+ @Override public void updateNClob(String columnLabel, Reader reader) {
+ }
+
+ @Override public T getObject(int columnIndex, Class type) {
+ return null;
+ }
+
+ @Override public T getObject(String columnLabel, Class type) {
+ return null;
+ }
+
+ @Override public T unwrap(Class iface) {
+ return null;
+ }
+
+ @Override public boolean isWrapperFor(Class> iface) {
+ return false;
+ }
+ }
+
+ /** Implementation of {@link java.sql.ResultSetMetaData} that implements every
+ * method but mostly does nothing. Use it as a base class. */
+ private static class NullResultSetMetaData implements ResultSetMetaData {
+ @Override public int getColumnCount() {
+ return 0;
+ }
+
+ @Override public boolean isAutoIncrement(int column) {
+ return false;
+ }
+
+ @Override public boolean isCaseSensitive(int column) {
+ return false;
+ }
+
+ @Override public boolean isSearchable(int column) {
+ return false;
+ }
+
+ @Override public boolean isCurrency(int column) {
+ return false;
+ }
+
+ @Override public int isNullable(int column) {
+ return columnNullableUnknown;
+ }
+
+ @Override public boolean isSigned(int column) {
+ return false;
+ }
+
+ @Override public int getColumnDisplaySize(int column) {
+ return 0;
+ }
+
+ @Override public String getColumnLabel(int column) {
+ return "";
+ }
+
+ @Override public String getColumnName(int column) {
+ return "";
+ }
+
+ @Override public String getSchemaName(int column) {
+ return "";
+ }
+
+ @Override public int getPrecision(int column) {
+ return 0;
+ }
+
+ @Override public int getScale(int column) {
+ return 0;
+ }
+
+ @Override public String getTableName(int column) {
+ return "";
+ }
+
+ @Override public String getCatalogName(int column) {
+ return "";
+ }
+
+ @Override public int getColumnType(int column) {
+ return 0;
+ }
+
+ @Override public String getColumnTypeName(int column) {
+ return "";
+ }
+
+ @Override public boolean isReadOnly(int column) {
+ return false;
+ }
+
+ @Override public boolean isWritable(int column) {
+ return false;
+ }
+
+ @Override public boolean isDefinitelyWritable(int column) {
+ return false;
+ }
+
+ @Override public String getColumnClassName(int column) {
+ return "";
+ }
+
+ @Override public T unwrap(Class iface) {
+ return null;
+ }
+
+ @Override public boolean isWrapperFor(Class> iface) {
+ return false;
+ }
+ }
+}
+
+// End JdbcUtils.java
diff --git a/src/main/java/net/hydromatic/quidem/record/Mode.java b/src/main/java/net/hydromatic/quidem/record/Mode.java
new file mode 100644
index 0000000..c2731d6
--- /dev/null
+++ b/src/main/java/net/hydromatic/quidem/record/Mode.java
@@ -0,0 +1,26 @@
+/*
+ * Licensed to Julian Hyde under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package net.hydromatic.quidem.record;
+
+/** Recording mode.
+ *
+ * @see Config#withMode(Mode) */
+public enum Mode {
+ PLAY, RECORD, PASS_THROUGH
+}
+
+// End Mode.java
diff --git a/src/main/java/net/hydromatic/quidem/record/Recorder.java b/src/main/java/net/hydromatic/quidem/record/Recorder.java
new file mode 100644
index 0000000..4a3cf1b
--- /dev/null
+++ b/src/main/java/net/hydromatic/quidem/record/Recorder.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to Julian Hyde under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package net.hydromatic.quidem.record;
+
+import java.sql.ResultSet;
+import java.util.function.Consumer;
+
+/** Object that can execute queries and return their response.
+ *
+ * Depending on its {@link Mode}, the recorder executes each query on a
+ * backing database, generating a recording file as it goes, or answers each
+ * query by consulting an existing recording.
+ *
+ *
The modes allow you to run compliance tests in environments where the
+ * backend database is not available.
+ *
+ *
Created via {@link Recorders#config()}.
+ */
+public interface Recorder extends AutoCloseable {
+ /** Executes a query and calls {@code consumer} with the {@link ResultSet}
+ * containing the results of the query. */
+ void executeQuery(String db, String name, String sql,
+ Consumer consumer);
+
+ /** {@inheritDoc}
+ *
+ * Unlike the method in the base class,
+ * never throws an unchecked exception.
+ */
+ @Override void close();
+}
+
+// End Config.java
diff --git a/src/main/java/net/hydromatic/quidem/record/Recorders.java b/src/main/java/net/hydromatic/quidem/record/Recorders.java
new file mode 100644
index 0000000..00b6acd
--- /dev/null
+++ b/src/main/java/net/hydromatic/quidem/record/Recorders.java
@@ -0,0 +1,400 @@
+/*
+ * Licensed to Julian Hyde under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package net.hydromatic.quidem.record;
+
+import net.hydromatic.quidem.ConnectionFactories;
+import net.hydromatic.quidem.Quidem;
+
+import com.google.common.base.Charsets;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSortedMap;
+
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.FileReader;
+import java.io.IOException;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import java.util.Objects;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.function.Consumer;
+
+import static java.lang.String.format;
+import static java.util.Objects.requireNonNull;
+
+/** Utilities for recording and playback. */
+public abstract class Recorders {
+ // utility class
+ private Recorders() {
+ }
+
+ /** Creates an immutable empty configuration. */
+ public static Config config() {
+ return ConfigImpl.EMPTY;
+ }
+
+ /** Creates a Recorder. */
+ public static Recorder create(Config config_) {
+ final ConfigImpl config = (ConfigImpl) config_;
+ switch (config.mode) {
+ case RECORD:
+ return new RecordingRecorder(config);
+ case PLAY:
+ return new PlayingRecorder(config);
+ case PASS_THROUGH:
+ return new PassThroughRecorder(config);
+ default:
+ throw new AssertionError(config.mode);
+ }
+ }
+
+ /** Implementation of {@link Config}. */
+ private static class ConfigImpl implements Config {
+ static final ConfigImpl EMPTY =
+ new ConfigImpl(null, Mode.PLAY, ConnectionFactories.unsupported());
+
+ final @Nullable File file;
+ final Mode mode;
+ final Quidem.ConnectionFactory connectionFactory;
+
+ private ConfigImpl(@Nullable File file, Mode mode,
+ Quidem.ConnectionFactory connectionFactory) {
+ this.file = file;
+ this.mode = requireNonNull(mode);
+ this.connectionFactory = requireNonNull(connectionFactory);
+ }
+
+ @Override public Config withFile(File file) {
+ return new ConfigImpl(file, mode, connectionFactory);
+ }
+
+ @Override public Config withMode(Mode mode) {
+ return new ConfigImpl(file, mode, connectionFactory);
+ }
+
+ @Override public Config withConnectionFactory(
+ Quidem.ConnectionFactory connectionFactory) {
+ return new ConfigImpl(file, mode, connectionFactory);
+ }
+ }
+
+ /** Abstract implementation of {@link Recorder}. */
+ private abstract static class RecorderImpl implements Recorder {
+ protected final ConfigImpl config;
+
+ RecorderImpl(ConfigImpl config) {
+ this.config = requireNonNull(config, "config");
+ }
+
+ @Override public void close() {
+ }
+ }
+
+ /** Recorder that has a file. */
+ private abstract static class RecorderWithFile extends RecorderImpl {
+ protected final File file;
+
+ RecorderWithFile(ConfigImpl config) {
+ super(config);
+ if (config.file == null) {
+ throw new IllegalStateException(
+ format("mode '%s' requires a file", config.mode));
+ }
+ this.file = requireNonNull(config.file);
+ }
+ }
+
+ /** Recorder in PLAY mode. */
+ private static class PlayingRecorder extends RecorderWithFile {
+ final SortedMap sectionsByName;
+ final ImmutableMap sectionsBySql;
+
+ PlayingRecorder(ConfigImpl config) {
+ super(config);
+ final ImmutableSortedMap.Builder nameBuilder =
+ ImmutableSortedMap.naturalOrder();
+ final ImmutableMap.Builder sqlBuilder =
+ ImmutableMap.builder();
+ populate(file, section -> {
+ nameBuilder.put(section.name, section);
+ sqlBuilder.put(new StringPair(section.db, section.sql), section);
+ });
+ sectionsByName = nameBuilder.build();
+ sectionsBySql = sqlBuilder.build();
+ }
+
+ private void populate(File file, Consumer consumer) {
+ try (FileReader fr = new FileReader(file);
+ BufferedReader br = new BufferedReader(fr)) {
+ final SectionBuilder sectionBuilder = new SectionBuilder();
+ sectionBuilder.parse(br, consumer);
+ } catch (IOException e) {
+ throw new RuntimeException(
+ format("file parsing file '%s'", file), e);
+ }
+ }
+
+ @Override public void executeQuery(String db, String name, String sql,
+ Consumer consumer) {
+ final Section section = sectionsBySql.get(new StringPair(db, sql));
+ if (section == null) {
+ throw new IllegalArgumentException(
+ format("sql [%s] is not in recording", sql));
+ }
+ section.toResultSet(consumer);
+ }
+ }
+
+ /** Recorder in RECORD mode. */
+ private static class RecordingRecorder extends RecorderWithFile {
+ final SortedMap sections = new TreeMap<>();
+
+ RecordingRecorder(ConfigImpl config) {
+ super(config);
+ }
+
+ @Override public void executeQuery(String db, String name, String sql,
+ Consumer consumer) {
+ try (Connection connection =
+ config.connectionFactory.connect(db, false)) {
+ if (connection == null) {
+ throw new IllegalStateException("unknown connection " + db);
+ }
+ try (Statement statement = connection.createStatement();
+ ResultSet resultSet = statement.executeQuery(sql)) {
+ final StringBuilder b = new StringBuilder();
+
+ // Generate the string fragment representing the result set
+ JdbcUtils.write(b, resultSet);
+ final String resultSetString = b.toString();
+ b.setLength(0);
+
+ // Put the segment into the table to be written on close, and unparse
+ // the result set string into the consumer's result set.
+ final Section section =
+ new Section(-1, name, db, sql, resultSetString);
+ sections.put(name, section);
+ section.toResultSet(consumer);
+ }
+ } catch (Exception e) {
+ throw new RuntimeException(format("while executing query [%s]", sql),
+ e);
+ }
+ }
+
+ @Override public void close() {
+ try (FileOutputStream fos = new FileOutputStream(file);
+ OutputStreamWriter osw =
+ new OutputStreamWriter(fos, Charsets.ISO_8859_1);
+ BufferedWriter w = new BufferedWriter(osw);
+ PrintWriter pw = new PrintWriter(w)) {
+ sections.forEach((name, segment) -> segment.send(pw));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ /** Recorder in PASS_TROUGH mode. */
+ private static class PassThroughRecorder extends RecorderImpl {
+ PassThroughRecorder(ConfigImpl config) {
+ super(config);
+ }
+
+ @Override public void executeQuery(String db, String name, String sql,
+ Consumer consumer) {
+ try (Connection connection =
+ config.connectionFactory.connect(db, false)) {
+ if (connection == null) {
+ throw new IllegalStateException("unknown connection " + db);
+ }
+ try (Statement statement = connection.createStatement();
+ ResultSet resultSet = statement.executeQuery(sql)) {
+ consumer.accept(resultSet);
+ }
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ /** Builder for {@link Section}. */
+ private static class SectionBuilder {
+ int offset; // offset within file
+ int sectionStart;
+ String name;
+ String db;
+ String sql;
+ String result;
+
+ Section build() {
+ return new Section(offset, name, db, sql, result);
+ }
+
+ private void clear() {
+ name = db = sql = result = null;
+ sectionStart = -1;
+ }
+
+ void end(Consumer consumer) {
+ consumer.accept(build());
+ clear();
+ }
+
+ public void parse(BufferedReader br, Consumer consumer)
+ throws IOException {
+ // File structure:
+ // preamble
+ // # StartTest: T1
+ // !use db1
+ // query1;
+ // result1
+ // !ok
+ // # EndTest: T1
+ // # StartTest: T2
+ // !use db2
+ // query2;
+ // result2
+ // !ok
+ // # EndTest: T2
+ // postamble
+ final StringBuilder b = new StringBuilder();
+ for (;;) {
+ String line = br.readLine();
+ if (line == null) {
+ break;
+ }
+ offset += line.length() + 1;
+ if (line.startsWith("# EndTest: ")) {
+ final String sectionName =
+ line.substring("# EndTest: ".length()).trim();
+ if (!Objects.equals(sectionName, name)) {
+ throw new IllegalArgumentException(
+ format("end '%s' does not match start '%s'",
+ sectionName, name));
+ }
+ end(consumer);
+ continue;
+ }
+ if (line.startsWith("# StartTest: ")) {
+ if (name != null) {
+ end(consumer);
+ }
+ name = line.substring("# StartTest: ".length()).trim();
+ continue;
+ }
+ if (line.startsWith("!use ")) {
+ db = line.substring("!use ".length()).trim();
+ continue;
+ }
+ if (line.endsWith(";")) {
+ // Append the line except for the ';'
+ b.append(line, 0, line.length() - 1);
+ sql = b.toString();
+ b.setLength(0);
+ continue;
+ }
+ if (line.equals("!ok")) {
+ result = b.toString();
+ b.setLength(0);
+ continue;
+ }
+ // We are in the middle of a query or a result. Append the line.
+ b.append(line);
+ b.append("\n");
+ }
+ }
+ }
+
+ /** The information that defines a test: query and result. */
+ private static class Section {
+ /** Offset within the file. */
+ final int offset;
+ /** Section name. */
+ final String name;
+ /** Database name. */
+ final String db;
+ /** SQL query. */
+ final String sql;
+ /** Query result string. */
+ final String result;
+
+ private Section(int offset, String name, String db, String sql,
+ String result) {
+ this.offset = offset;
+ this.name = name;
+ this.db = db;
+ this.sql = sql;
+ this.result = result;
+ }
+
+ public void send(PrintWriter pw) {
+ // Generate the string fragment representing the test header,
+ // SQL query, result set, and test footer.
+ pw.print("# StartTest: " + name + "\n"
+ + "!use " + db + "\n"
+ + sql + ";\n"
+ + result
+ + "!ok\n"
+ + "# EndTest: " + name + "\n");
+ }
+
+ public void toResultSet(Consumer consumer) {
+ try {
+ JdbcUtils.read(result, consumer);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ /** Immutable pair of non-null strings. */
+ static class StringPair {
+ final String left;
+ final String right;
+
+ StringPair(String left, String right) {
+ this.left = left;
+ this.right = right;
+ }
+
+ @Override public String toString() {
+ return left + ":" + right;
+ }
+
+ @Override public int hashCode() {
+ return left.hashCode() * 37 + right.hashCode();
+ }
+
+ @Override public boolean equals(Object obj) {
+ return obj == this
+ || obj instanceof StringPair
+ && left.equals(((StringPair) obj).left)
+ && right.equals(((StringPair) obj).right);
+ }
+ }
+}
+
+// End Recorders.java
diff --git a/src/main/java/net/hydromatic/quidem/record/package-info.java b/src/main/java/net/hydromatic/quidem/record/package-info.java
new file mode 100644
index 0000000..8a1c76e
--- /dev/null
+++ b/src/main/java/net/hydromatic/quidem/record/package-info.java
@@ -0,0 +1,24 @@
+/*
+ * Licensed to Julian Hyde under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Record databases' responses to queries and play them back
+ * when the database is not around.
+ */
+package net.hydromatic.quidem.record;
+
+// End package-info.java
diff --git a/src/test/java/net/hydromatic/quidem/QuidemTest.java b/src/test/java/net/hydromatic/quidem/QuidemTest.java
index af15e07..cf7a114 100644
--- a/src/test/java/net/hydromatic/quidem/QuidemTest.java
+++ b/src/test/java/net/hydromatic/quidem/QuidemTest.java
@@ -19,6 +19,7 @@
import com.google.common.base.Suppliers;
import com.google.common.collect.ImmutableList;
+import org.checkerframework.checker.nullness.qual.Nullable;
import org.hamcrest.Matcher;
import org.junit.jupiter.api.Test;
@@ -1495,7 +1496,8 @@ public static String toLinux(String s) {
}
public static class FooFactory implements Quidem.ConnectionFactory {
- public Connection connect(String name, boolean reference) throws Exception {
+ public @Nullable Connection connect(String name, boolean reference)
+ throws Exception {
if (name.equals("foo")) {
return makeConnection(false);
}
diff --git a/src/test/java/net/hydromatic/quidem/RecordTest.java b/src/test/java/net/hydromatic/quidem/RecordTest.java
new file mode 100644
index 0000000..6d2cfcd
--- /dev/null
+++ b/src/test/java/net/hydromatic/quidem/RecordTest.java
@@ -0,0 +1,484 @@
+/*
+ * Licensed to Julian Hyde under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package net.hydromatic.quidem;
+
+import net.hydromatic.quidem.record.Config;
+import net.hydromatic.quidem.record.JdbcUtils;
+import net.hydromatic.quidem.record.Mode;
+import net.hydromatic.quidem.record.Recorder;
+import net.hydromatic.quidem.record.Recorders;
+import net.hydromatic.scott.data.hsqldb.ScottHsqldb;
+import net.hydromatic.steelwheels.data.hsqldb.SteelwheelsHsqldb;
+
+import com.google.common.base.Suppliers;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Types;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+import static net.hydromatic.quidem.TestUtils.hasContents;
+import static net.hydromatic.quidem.TestUtils.isLines;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.hasToString;
+import static org.hamcrest.Matchers.nullValue;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.core.IsNull.notNullValue;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import static java.util.Arrays.fill;
+
+/** Tests the recorder. */
+public class RecordTest {
+
+ static final Supplier TEMP_SUPPLIER =
+ Suppliers.memoize(() -> new TestUtils.FileFont("quidem-record-test"));
+
+ static Quidem.ConnectionFactory getScottHsqldb() {
+ return ConnectionFactories.simple("scott", ScottHsqldb.URI,
+ ScottHsqldb.USER, ScottHsqldb.PASSWORD);
+ }
+
+ static Quidem.ConnectionFactory getSteelwheelsHsqldb() {
+ return ConnectionFactories.simple("steelwheels", SteelwheelsHsqldb.URI,
+ SteelwheelsHsqldb.USER, SteelwheelsHsqldb.PASSWORD);
+ }
+
+ /** Records a file containing one query. */
+ @Test void testRecord() {
+ final File file = TEMP_SUPPLIER.get().file("testRecord", ".iq");
+ checkRecord(file, Mode.RECORD, getScottHsqldb());
+
+ final String[] lines = {
+ "# StartTest: empCount",
+ "!use scott",
+ "select count(*) from emp;",
+ "C1:BIGINT",
+ "14",
+ "!ok",
+ "# EndTest: empCount"
+ };
+ assertThat(file, hasContents(isLines(lines)));
+ }
+
+ @Test void testRecordSeveral() {
+ final File file = TEMP_SUPPLIER.get().file("testRecordSeveral", ".iq");
+ final Quidem.ConnectionFactory connectionFactory =
+ ConnectionFactories.chain(getScottHsqldb(), getSteelwheelsHsqldb());
+ final String[] lines = {
+ "# StartTest: empCount",
+ "!use scott",
+ "select count(*) from emp;",
+ "C1:BIGINT",
+ "14",
+ "!ok",
+ "# EndTest: empCount",
+ "# StartTest: managers",
+ "!use scott",
+ "select *",
+ "from emp",
+ "where job in ('MANAGER', 'PRESIDENT')",
+ "order by empno;",
+ "EMPNO:SMALLINT,ENAME:VARCHAR,JOB:VARCHAR,MGR:SMALLINT,HIREDATE:DATE,SAL:DECIMAL,COMM:DECIMAL,DEPTNO:TINYINT",
+ "7566,JONES,MANAGER,7839,1981-02-04,2975.00,,20",
+ "7698,BLAKE,MANAGER,7839,1981-01-05,2850.00,,30",
+ "7782,CLARK,MANAGER,7839,1981-06-09,2450.00,,10",
+ "7839,KING,PRESIDENT,,1981-11-17,5000.00,,10",
+ "!ok",
+ "# EndTest: managers",
+ "# StartTest: productCount",
+ "!use steelwheels",
+ "select count(*) as c",
+ "from \"products\";",
+ "C:BIGINT",
+ "110",
+ "!ok",
+ "# EndTest: productCount"
+ };
+ checkRecordSeveral(file, Mode.PASS_THROUGH, connectionFactory);
+ assertThat(file.exists(), is(false));
+ checkRecordSeveral(file, Mode.RECORD, connectionFactory);
+ assertThat(file.exists(), is(true));
+ assertThat(file, hasContents(isLines(lines)));
+ checkRecordSeveral(file, Mode.PLAY, connectionFactory);
+ }
+
+ private static void checkRecord(File file, Mode mode,
+ Quidem.ConnectionFactory connectionFactory) {
+ Config config = Recorders.config()
+ .withFile(file)
+ .withMode(mode)
+ .withConnectionFactory(connectionFactory);
+ try (Recorder recorder = Recorders.create(config)) {
+ recorder.executeQuery("scott", "empCount", "select count(*) from emp",
+ isInt(14));
+ }
+ }
+
+ private static void checkRecordSeveral(File file, Mode mode,
+ Quidem.ConnectionFactory connectionFactory) {
+ Config config = Recorders.config()
+ .withFile(file)
+ .withMode(mode)
+ .withConnectionFactory(connectionFactory);
+ try (Recorder recorder = Recorders.create(config)) {
+ recorder.executeQuery("scott", "empCount", "select count(*) from emp",
+ isInt(14));
+ recorder.executeQuery("scott", "managers",
+ "select *\n"
+ + "from emp\n"
+ + "where job in ('MANAGER', 'PRESIDENT')\n"
+ + "order by empno",
+ result -> {
+ try {
+ final ResultSetMetaData metaData = result.getMetaData();
+ assertThat(metaData.getColumnCount(), is(8));
+ assertThat(metaData.getColumnName(1), is("EMPNO"));
+ assertThat(metaData.getColumnTypeName(1), is("SMALLINT"));
+ assertThat(metaData.getColumnType(1), is(Types.SMALLINT));
+ assertThat(metaData.getColumnName(2), is("ENAME"));
+ assertThat(metaData.getColumnTypeName(2), is("VARCHAR"));
+ assertThat(metaData.getColumnType(2), is(Types.VARCHAR));
+ assertThat(metaData.getColumnName(3), is("JOB"));
+ assertThat(metaData.getColumnTypeName(3), is("VARCHAR"));
+ assertThat(metaData.getColumnType(3), is(Types.VARCHAR));
+ assertThat(metaData.getColumnName(4), is("MGR"));
+ assertThat(metaData.getColumnTypeName(4), is("SMALLINT"));
+ assertThat(metaData.getColumnType(4), is(Types.SMALLINT));
+ assertThat(metaData.getColumnName(5), is("HIREDATE"));
+ assertThat(metaData.getColumnTypeName(5), is("DATE"));
+ assertThat(metaData.getColumnType(5), is(Types.DATE));
+ assertThat(metaData.getColumnName(6), is("SAL"));
+ assertThat(metaData.getColumnTypeName(6), is("DECIMAL"));
+ assertThat(metaData.getColumnType(6), is(Types.DECIMAL));
+ assertThat(metaData.getColumnName(7), is("COMM"));
+ assertThat(metaData.getColumnTypeName(7), is("DECIMAL"));
+ assertThat(metaData.getColumnType(7), is(Types.DECIMAL));
+ assertThat(metaData.getColumnName(8), is("DEPTNO"));
+ assertThat(metaData.getColumnTypeName(8), is("TINYINT"));
+ assertThat(metaData.getColumnType(8), is(Types.TINYINT));
+ assertThat(result.next(), is(true));
+ assertThat(result.getInt(1), is(7566)); // EMPNO
+ assertThat(result.getString(2), is("JONES")); // ENAME
+ assertThat(result.getDate(5),
+ hasToString("1981-02-04")); // HIREDATE
+ assertThat(result.getBigDecimal(6),
+ hasToString("2975.00")); // SAL
+ assertThat(result.getBigDecimal(7), nullValue()); // COMM
+ } catch (SQLException e) {
+ fail(e);
+ }
+ });
+ recorder.executeQuery("steelwheels", "productCount",
+ "select count(*) as c\n"
+ + "from \"products\"",
+ isInt(110));
+ }
+ }
+
+ /** Tests what happens if there is no file. */
+ @Test void testNoFile() {
+ for (Mode mode : Mode.values()) {
+ switch (mode) {
+ case PLAY:
+ case RECORD:
+ // PLAY and RECORD require a file
+ try (Recorder recorder =
+ Recorders.create(Recorders.config().withMode(mode))) {
+ fail("expected error, got " + recorder);
+ } catch (IllegalStateException e) {
+ assertThat(e.getMessage(),
+ is("mode '" + mode + "' requires a file"));
+ }
+ break;
+
+ case PASS_THROUGH:
+ // PASS_THROUGH does not require a file
+ try (Recorder recorder =
+ Recorders.create(Recorders.config().withMode(mode))) {
+ assertThat(recorder, notNullValue());
+ }
+ break;
+
+ default:
+ throw new AssertionError(mode);
+ }
+ }
+ }
+
+ /** Creates a recording with two queries, tries to execute a query that is
+ * missing. */
+ @Test void testPlaySeveral() {
+ final File file = TEMP_SUPPLIER.get().file("testPlaySeveral", ".iq");
+ final String[] lines = {
+ "# StartTest: one-scott",
+ "!use scott",
+ "select 1;",
+ "C1:BIGINT",
+ "1",
+ "!ok",
+ "# EndTest: one-scott",
+ "# StartTest: one-steelwheels",
+ "!use steelwheels",
+ "select 1;",
+ "C1:BIGINT",
+ "100",
+ "!ok",
+ "# EndTest: one-steelwheels",
+ "# StartTest: three",
+ "!use scott",
+ "select 3;",
+ "C1:BIGINT",
+ "3",
+ "!ok",
+ "# EndTest: three"
+ };
+ try (FileWriter w = new FileWriter(file);
+ PrintWriter pw = new PrintWriter(w)) {
+ for (String line : lines) {
+ pw.println(line);
+ }
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ final Quidem.ConnectionFactory connectionFactory =
+ ConnectionFactories.chain(getScottHsqldb(), getSteelwheelsHsqldb());
+ Config config = Recorders.config()
+ .withFile(file)
+ .withMode(Mode.PLAY)
+ .withConnectionFactory(connectionFactory);
+ try (Recorder recorder = Recorders.create(config)) {
+ recorder.executeQuery("scott", "one", "select 1", isInt(1));
+ recorder.executeQuery("scott", "three", "select 3", isInt(3));
+ recorder.executeQuery("steelwheels", "one", "select 1", isInt(100));
+ try {
+ recorder.executeQuery("scott", "one", "select 2",
+ resultSet -> fail("should not be called"));
+ fail("should not reach this point");
+ } catch (IllegalArgumentException e) {
+ assertThat(e.getMessage(), is("sql [select 2] is not in recording"));
+ }
+ }
+ }
+
+ /** Returns a consumer that checks that a {@link java.sql.ResultSet} has
+ * one integer column, one row, and the value in that row is {@code value}. */
+ private static Consumer isInt(int value) {
+ return result -> {
+ try {
+ assertThat(result.getMetaData().getColumnCount(), is(1));
+ assertThat(result.next(), is(true));
+ assertThat(result.getInt(1), is(value));
+ assertThat(result.next(), is(false));
+ } catch (SQLException e) {
+ fail(e);
+ }
+ };
+ }
+
+ /** Tests a query where one of the columns contains null, empty, and non-empty
+ * strings. It is challenging to find an encoding that can distinguish between
+ * null and empty string values. */
+ @Test void testNullAndEmptyString() {
+ final Quidem.ConnectionFactory connectionFactory =
+ ConnectionFactories.chain(getScottHsqldb(), getSteelwheelsHsqldb());
+ final String sql = "with t as\n"
+ + " (select ename, substr(ename, 1, mod(mgr, 5) - 1) as e\n"
+ + " from emp)\n"
+ + "select ename, e, e is null as n, char_length(e) as len\n"
+ + "from t";
+ final String[] strings =
+ recordAndPlay("testNullAndEmptyString", connectionFactory, "scott",
+ sql);
+ assertThat(strings[0], is(strings[1]));
+ }
+
+ /** Runs a query twice - once in RECORD mode, once in PLAY mode - and returns
+ * the results from both. */
+ private static String[] recordAndPlay(String testName,
+ Quidem.ConnectionFactory connectionFactory, String db, String sql) {
+ final File file = TEMP_SUPPLIER.get().file(testName, ".iq");
+ Config config = Recorders.config()
+ .withFile(file)
+ .withConnectionFactory(connectionFactory);
+ final StringBuilder b = new StringBuilder();
+ try (Recorder recorder = Recorders.create(config.withMode(Mode.RECORD))) {
+ recorder.executeQuery(db, "a query", sql, resultSet -> {
+ try {
+ JdbcUtils.write(b, resultSet);
+ } catch (SQLException e) {
+ fail(
+ String.format("error while serializing result set: sql [%s]",
+ sql), e);
+ }
+ });
+ }
+ final StringBuilder b2 = new StringBuilder();
+ try (Recorder recorder = Recorders.create(config.withMode(Mode.PLAY))) {
+ recorder.executeQuery(db, "a query", sql, resultSet -> {
+ try {
+ JdbcUtils.write(b2, resultSet);
+ } catch (SQLException e) {
+ fail(
+ String.format("error while serializing result set: sql [%s]",
+ sql), e);
+ }
+ });
+ }
+ return new String[]{b.toString(), b2.toString()};
+ }
+
+ /** Tests {@link JdbcUtils#parse(java.lang.String[], java.lang.String)},
+ * which parses a comma-separated line. */
+ @Test void testParse() {
+ final String[] fields = new String[3];
+
+ // Simple case
+ fill(fields, "xxx");
+ JdbcUtils.parse(fields, "wx,y,z");
+ assertThat(fields[0], is("wx"));
+ assertThat(fields[1], is("y"));
+ assertThat(fields[2], is("z"));
+
+ // Empty at end
+ fill(fields, "xxx");
+ JdbcUtils.parse(fields, "x,y,");
+ assertThat(fields[0], is("x"));
+ assertThat(fields[1], is("y"));
+ assertThat(fields[2], nullValue());
+
+ // Too few commas
+ fill(fields, "xxx");
+ JdbcUtils.parse(fields, "x,y");
+ assertThat(fields[0], is("x"));
+ assertThat(fields[1], is("y"));
+ assertThat(fields[2], nullValue());
+
+ // Too few commas
+ fill(fields, "xxx");
+ JdbcUtils.parse(fields, "x");
+ assertThat(fields[0], is("x"));
+ assertThat(fields[1], nullValue());
+ assertThat(fields[2], nullValue());
+
+ // Empty field in middle
+ fill(fields, "xxx");
+ JdbcUtils.parse(fields, "x,,z");
+ assertThat(fields[0], is("x"));
+ assertThat(fields[1], nullValue());
+ assertThat(fields[2], is("z"));
+
+ // Empty field at start
+ fill(fields, "xxx");
+ JdbcUtils.parse(fields, ",y,z");
+ assertThat(fields[0], nullValue());
+ assertThat(fields[1], is("y"));
+ assertThat(fields[2], is("z"));
+
+ // Empty field at start and middle
+ fill(fields, "xxx");
+ JdbcUtils.parse(fields, ",,z");
+ assertThat(fields[0], nullValue());
+ assertThat(fields[1], nullValue());
+ assertThat(fields[2], is("z"));
+
+ // Empty line
+ fill(fields, "xxx");
+ JdbcUtils.parse(fields, "");
+ assertThat(fields[0], nullValue());
+ assertThat(fields[1], nullValue());
+ assertThat(fields[2], nullValue());
+
+ // Quoted first field
+ fill(fields, "xxx");
+ JdbcUtils.parse(fields, "'x,a',y,z");
+ assertThat(fields[0], is("x,a"));
+ assertThat(fields[1], is("y"));
+ assertThat(fields[2], is("z"));
+
+ // Single-quote in quoted field
+ fill(fields, "xxx");
+ JdbcUtils.parse(fields, "'x''a','y''b','z''c'");
+ assertThat(fields[0], is("x'a"));
+ assertThat(fields[1], is("y'b"));
+ assertThat(fields[2], is("z'c"));
+
+ // Single-quote at end of quoted field
+ fill(fields, "xxx");
+ JdbcUtils.parse(fields, "'''x''a''','''y''b''','''z''c'''");
+ assertThat(fields[0], is("'x'a'"));
+ assertThat(fields[1], is("'y'b'"));
+ assertThat(fields[2], is("'z'c'"));
+
+ // Quoted middle field
+ fill(fields, "xxx");
+ JdbcUtils.parse(fields, "x,'y,a',z");
+ assertThat(fields[0], is("x"));
+ assertThat(fields[1], is("y,a"));
+ assertThat(fields[2], is("z"));
+
+ // Quoted last field
+ fill(fields, "xxx");
+ JdbcUtils.parse(fields, "x,y,'z,a'");
+ assertThat(fields[0], is("x"));
+ assertThat(fields[1], is("y"));
+ assertThat(fields[2], is("z,a"));
+
+ // Quoted last field, too few fields
+ fill(fields, "xxx");
+ JdbcUtils.parse(fields, "x,'y,a'");
+ assertThat(fields[0], is("x"));
+ assertThat(fields[1], is("y,a"));
+ assertThat(fields[2], nullValue());
+
+ // Line ends following an escaped single-quote
+ fill(fields, "xxx");
+ try {
+ JdbcUtils.parse(fields, "x,y,'z''");
+ fail("expected error");
+ } catch (IllegalArgumentException e) {
+ assertThat(e.getMessage(), is("missing \"'\" following escaped \"'\""));
+ }
+
+ // Single-quote is not end of field
+ fill(fields, "xxx");
+ try {
+ JdbcUtils.parse(fields, "x,'y,a'b,z");
+ fail("expected error");
+ } catch (IllegalArgumentException e) {
+ assertThat(e.getMessage(),
+ is("quoted string must be followed by comma or line ending"));
+ }
+
+ // Single field
+ final String[] fields1 = new String[1];
+ fill(fields1, "xxx");
+ JdbcUtils.parse(fields1, "123");
+ assertThat(fields1[0], is("123"));
+ }
+}
+
+// End RecordTest.java
diff --git a/src/test/java/net/hydromatic/quidem/TestUtils.java b/src/test/java/net/hydromatic/quidem/TestUtils.java
new file mode 100644
index 0000000..93696ed
--- /dev/null
+++ b/src/test/java/net/hydromatic/quidem/TestUtils.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to Julian Hyde under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to you under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package net.hydromatic.quidem;
+
+import com.google.common.base.Charsets;
+import com.google.common.io.Files;
+
+import org.hamcrest.CustomTypeSafeMatcher;
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.hamcrest.core.Is.is;
+
+/** Utilities for writing Quidem tests. */
+public class TestUtils {
+ private TestUtils() {
+ }
+
+ /** Returns a matcher that concatenates an array of strings into a multi-line
+ * string. */
+ public static Matcher isLines(String... lines) {
+ final StringBuilder b = new StringBuilder();
+ for (String line : lines) {
+ b.append(line).append("\n");
+ }
+ return is(b.toString());
+ }
+
+ /** Returns a matcher that checks the string contents of a file. */
+ static Matcher super File> hasContents(Matcher matcher) {
+ return new CustomTypeSafeMatcher("file contents") {
+ @Override protected void describeMismatchSafely(File file,
+ Description mismatchDescription) {
+ mismatchDescription.appendText("file has contents [")
+ .appendText(fileContents(file))
+ .appendText("]");
+ }
+
+ @Override protected boolean matchesSafely(File file) {
+ return matcher.matches(fileContents(file));
+ }
+
+ String fileContents(File file) {
+ try {
+ return Files.asCharSource(file, Charsets.ISO_8859_1).read();
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ };
+ }
+
+ /** Supplies a sequence of unique file names in a temporary directory that
+ * will be deleted when the JVM finishes. */
+ public static class FileFont {
+ private final File file;
+ private final AtomicInteger i = new AtomicInteger();
+
+ /** Creates a FileFont. */
+ public FileFont(String dirName) {
+ try {
+ Path p = java.nio.file.Files.createTempDirectory(dirName);
+ file = p.toFile();
+ file.deleteOnExit();
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /** Generates a unique file in the temporary directory.
+ *
+ * If you call {@code file("foo", ".iq")}
+ * the file name might be something like
+ * "{@code /tmp/quidem-record-test123/foo_3.iq}". */
+ public File file(String name, String suffix) {
+ return new File(file, name + '_' + i.getAndIncrement() + suffix);
+ }
+ }
+}
+
+// End TestUtils.java