diff --git a/pom.xml b/pom.xml index 332a765..d927b2b 100644 --- a/pom.xml +++ b/pom.xml @@ -62,6 +62,7 @@ 3.42.0 10.12.7 + 0.5 4.9.10 33.0.0-jre @@ -77,6 +78,7 @@ 3.3.0 3.2.5 0.2 + 0.2 -html5 https://docs.oracle.com/en/java/javase/17/docs/api/ @@ -133,12 +135,24 @@ ${hsqldb.version} test + + net.hydromatic + foodmart-data-hsqldb + ${foodmart-data-hsqldb.version} + test + net.hydromatic scott-data-hsqldb ${scott-data-hsqldb.version} test + + net.hydromatic + steelwheels-data-hsqldb + ${steelwheels-data-hsqldb.version} + test + org.junit.jupiter junit-jupiter-api diff --git a/src/main/java/net/hydromatic/quidem/ChainingConnectionFactory.java b/src/main/java/net/hydromatic/quidem/ChainingConnectionFactory.java index fdf33b8..40139fa 100644 --- a/src/main/java/net/hydromatic/quidem/ChainingConnectionFactory.java +++ b/src/main/java/net/hydromatic/quidem/ChainingConnectionFactory.java @@ -18,6 +18,8 @@ import com.google.common.collect.ImmutableList; +import org.checkerframework.checker.nullness.qual.Nullable; + import java.sql.Connection; import java.util.List; @@ -26,11 +28,12 @@ class ChainingConnectionFactory implements Quidem.ConnectionFactory { private final List factories; - ChainingConnectionFactory(List factories) { + ChainingConnectionFactory( + Iterable 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 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 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