package arjdbc.postgresql; import arjdbc.jdbc.JdbcResult; import arjdbc.jdbc.RubyJdbcConnection; import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.SQLException; import java.sql.Types; import arjdbc.util.PG; import org.jruby.Ruby; import org.jruby.RubyArray; import org.jruby.RubyClass; import org.jruby.RubyHash; import org.jruby.RubyModule; import org.jruby.RubyNumeric; import org.jruby.RubyString; import org.jruby.anno.JRubyMethod; import org.jruby.runtime.Block; import org.jruby.runtime.Helpers; import org.jruby.runtime.ObjectAllocator; import org.jruby.runtime.ThreadContext; import org.jruby.runtime.builtin.IRubyObject; /* * This class mimics the PG::Result class enough to get by. It also adorns common methods useful for * gems like mini_sql to consume it similarly to PG::Result */ public class PostgreSQLResult extends JdbcResult { private RubyArray fields = null; // lazily created if PG fields method is called. // These are needed when generating an AR::Result private final ResultSetMetaData resultSetMetaData; /********* JRuby compat methods ***********/ static RubyClass createPostgreSQLResultClass(Ruby runtime, RubyClass postgreSQLConnection) { RubyClass rubyClass = postgreSQLConnection.defineClassUnder("Result", runtime.getObject(), ObjectAllocator.NOT_ALLOCATABLE_ALLOCATOR); rubyClass.defineAnnotatedMethods(PostgreSQLResult.class); rubyClass.includeModule(runtime.getEnumerable()); return rubyClass; } /** * Generates a new PostgreSQLResult object for the given result set * @param context current thread context * @param clazz metaclass for this result object * @param resultSet the set of results that should be returned * @return an instantiated result object * @throws SQLException throws! */ static PostgreSQLResult newResult(ThreadContext context, RubyClass clazz, PostgreSQLRubyJdbcConnection connection, ResultSet resultSet) throws SQLException { return new PostgreSQLResult(context, clazz, connection, resultSet); } /********* End JRuby compat methods ***********/ private PostgreSQLResult(ThreadContext context, RubyClass clazz, RubyJdbcConnection connection, ResultSet resultSet) throws SQLException { super(context, clazz, connection, resultSet); resultSetMetaData = resultSet.getMetaData(); } /** * Generates a type map to be given to the AR::Result object * @param context current thread context * @return RubyHash RubyString - column name, Type::Value - type object) * @throws SQLException if it fails to get the field */ @Override protected IRubyObject columnTypeMap(final ThreadContext context) throws SQLException { Ruby runtime = context.runtime; RubyHash types = RubyHash.newHash(runtime); int columnCount = columnNames.length; IRubyObject adapter = connection.adapter(context); for (int i = 0; i < columnCount; i++) { int col = i + 1; String typeName = resultSetMetaData.getColumnTypeName(col); int mod = 0; if ("numeric".equals(typeName)) { // this field is only relevant for "numeric" type in AR // AR checks (fmod - 4 & 0xffff).zero? // pgjdbc: // - for typmod == -1, getScale() and getPrecision() return 0 // - for typmod != -1, getScale() returns "(typmod - 4) & 0xFFFF;" mod = resultSetMetaData.getScale(col); mod = mod == 0 && resultSetMetaData.getPrecision(col) == 0 ? -1 : mod + 4; } final RubyString name = columnNames[i]; final IRubyObject type = Helpers.invoke(context, adapter, "get_oid_type", runtime.newString(typeName), runtime.newFixnum(mod), name); if (!type.isNil()) types.fastASet(name, type); } return types; } /** * This is to support the Enumerable module. * This is needed when setting up the type maps so the Enumerable methods work * @param context the thread this is being executed on * @param block which may handle each result * @return this object or RubyNil */ @PG @JRubyMethod public IRubyObject each(ThreadContext context, Block block) { // At this point we don't support calling this without a block if (block.isGiven()) { if (tuples == null) { populateTuples(context); } for (RubyHash tuple : tuples) { block.yield(context, tuple); } return this; } else { return context.nil; } } private RubyClass getBinaryDataClass(final ThreadContext context) { return ((RubyModule) context.runtime.getModule("ActiveModel").getConstantAt("Type")).getClass("Binary").getClass("Data"); } private boolean isBinaryType(final int type) { return type == Types.BLOB || type == Types.BINARY || type == Types.VARBINARY || type == Types.LONGVARBINARY; } /** * Gives the number of rows to be returned. * currently defined so we match existing returned results * @param context current thread contect * @return Fixnum */ @PG @JRubyMethod(name = {"length", "ntuples", "num_tuples"}) public IRubyObject length(final ThreadContext context) { return values.length(); } /** * Creates an ActiveRecord::Result with the data from this result. * Overriding the base method so we can modify binary data columns first to mark them * as already unencoded * @param context current thread context * @return ActiveRecord::Result object with the data from this result set * @throws SQLException can be caused by postgres generating its type map */ @Override @SuppressWarnings("unchecked") public IRubyObject toARResult(final ThreadContext context) throws SQLException { RubyClass BinaryDataClass = null; int rowCount = 0; // This is destructive, but since this is typically the final // use of the rows I'm going to leave it this way unless it becomes an issue for (int columnIndex = 0; columnIndex < columnTypes.length; columnIndex++) { if (isBinaryType(columnTypes[columnIndex])) { // Convert the values in this column to ActiveModel::Type::Binary::Data instances // so AR knows it has already been unescaped if (BinaryDataClass == null) { BinaryDataClass = getBinaryDataClass(context); rowCount = values.getLength(); } for (int rowIndex = 0; rowIndex < rowCount; rowIndex++) { RubyArray row = (RubyArray) values.eltInternal(rowIndex); IRubyObject value = row.eltInternal(columnIndex); if (value != context.nil) { row.eltInternalSet(columnIndex, BinaryDataClass.newInstance(context, value, Block.NULL_BLOCK)); } } } } return super.toARResult(context); } /** * Returns an array of arrays of the values in the result. * This is defined in PG::Result and is used by some Rails tests * @return IRubyObject RubyArray of RubyArray of values */ @PG @JRubyMethod public IRubyObject values() { return values; } /** * Do we have any rows of result * @param context the thread context * @return number of rows */ @JRubyMethod(name = "empty?") public IRubyObject isEmpty(ThreadContext context) { return context.runtime.newBoolean(values.isEmpty()); } @PG @JRubyMethod public RubyArray fields(ThreadContext context) { if (fields == null) fields = RubyArray.newArrayNoCopy(context.runtime, getColumnNames()); return fields; } @PG @JRubyMethod(name = {"nfields", "num_fields"}) public IRubyObject nfields(ThreadContext context) { return context.runtime.newFixnum(getColumnNames().length); } @PG @JRubyMethod public IRubyObject getvalue(ThreadContext context, IRubyObject rowArg, IRubyObject columnArg) { int rows = values.size(); int row = RubyNumeric.fix2int(rowArg); int column = RubyNumeric.fix2int(columnArg); if (row < 0 || row >= rows) throw context.runtime.newArgumentError("invalid tuple number " + row); if (column < 0 || column >= getColumnNames().length) throw context.runtime.newArgumentError("invalid field number " + row); return ((RubyArray) values.eltInternal(row)).eltInternal(column); } @PG @JRubyMethod(name = "[]") public IRubyObject aref(ThreadContext context, IRubyObject rowArg) { int row = RubyNumeric.fix2int(rowArg); int rows = values.size(); if (row < 0 || row >= rows) throw context.runtime.newArgumentError("Index " + row + " is out of range"); RubyArray rowValues = (RubyArray) values.eltOk(row); RubyHash resultHash = RubyHash.newSmallHash(context.runtime); RubyArray fields = fields(context); int length = rowValues.getLength(); for (int i = 0; i < length; i++) { resultHash.op_aset(context, fields.eltOk(i), rowValues.eltOk(i)); } return resultHash; } // Note: this is # of commands (insert/update/selects performed) and not number of rows. In practice, // so far users always just check this as to when it is 0 which ends up being the same as an update/insert // where no rows were affected...so wrong value but the important value will be the same (I do not see // how jdbc can do this). @PG @JRubyMethod(name = {"cmdtuples", "cmd_tuples"}) public IRubyObject cmdtuples(ThreadContext context) { return values.isEmpty() ? context.runtime.newFixnum(0) : aref(context, context.runtime.newFixnum(0)); } }