ArrayType.java

/*
 *
 * The DbUnit Database Testing Framework
 * Copyright (C)2002-2026, DbUnit.org
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 2.1 of the License, or (at your option) any later version.
 *
 * This library is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this library; if not, write to the Free Software
 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 *
 */
package org.dbunit.ext.postgresql;

import java.sql.Array;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

import org.dbunit.dataset.datatype.AbstractDataType;
import org.dbunit.dataset.datatype.TypeCastException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Adapter to handle conversion between PostgreSQL array columns
 * ({@link Types#ARRAY}, e.g. {@code integer[]}, {@code text[]}) and their
 * PostgreSQL literal text representation, such as {@code {1,2,3}} or
 * {@code {"a","b","c"}}.
 *
 * <p>
 * PostgreSQL reports every array column's sql type name as its element
 * type's own pg_catalog name prefixed with an underscore (for example
 * {@code _int4} for an {@code integer[]} column); {@link
 * PostgresqlDataTypeFactory} constructs the matching instance for each
 * column automatically, passing that name through unchanged. There is
 * normally no need to instantiate this class directly.
 *
 * <p>
 * This class round-trips a column's literal text between the driver and the
 * dataset; it does not typecast individual elements to their Java
 * equivalents, and comparisons against an expected dataset value are plain
 * string comparisons. On write, the literal text is split into its
 * top-level elements - honoring double-quoted elements (backslash-escaped
 * internally), backslash-escaped characters outside quotes, and the
 * unquoted {@code NULL} keyword - and bound via {@link
 * Connection#createArrayOf(String, Object[])}, letting PostgreSQL itself
 * parse and validate each element against the column's actual base type.
 * Only single-dimension arrays are supported for writing; a literal
 * containing a nested array (a multi-dimensional array) is rejected with a
 * {@link TypeCastException}. Reading one back for comparison or export works
 * fine either way, since the literal text is never parsed on read.
 *
 * @author Jeff Jensen
 * @since 3.5.0
 */
public class ArrayType extends AbstractDataType
{
    private static final Logger logger =
            LoggerFactory.getLogger(ArrayType.class);

    private final String sqlTypeName;
    private final String elementSqlTypeName;

    /**
     * Creates a data type adapter for the given PostgreSQL array sql type
     * name.
     *
     * @param sqlTypeName
     *            The sql type name reported for the array column, e.g.
     *            {@code "_int4"} for an {@code integer[]} column.
     */
    public ArrayType(final String sqlTypeName)
    {
        super(Objects.requireNonNull(sqlTypeName,
                "The parameter 'sqlTypeName' must not be null"), Types.ARRAY,
                String.class, false);

        this.sqlTypeName = sqlTypeName;
        this.elementSqlTypeName = sqlTypeName.startsWith("_")
                ? sqlTypeName.substring(1)
                : sqlTypeName;
    }

    @Override
    public Object getSqlValue(final int column, final ResultSet resultSet)
            throws SQLException, TypeCastException
    {
        final Array value = resultSet.getArray(column);
        return value == null ? null : value.toString();
    }

    @Override
    public void setSqlValue(final Object value, final int column,
            final PreparedStatement statement)
            throws SQLException, TypeCastException
    {
        if (value == null)
        {
            statement.setNull(column, Types.ARRAY);
            return;
        }

        if (value instanceof Array)
        {
            statement.setArray(column, (Array) value);
            return;
        }

        final String[] elements = parseElements(value.toString());
        final Connection connection = statement.getConnection();
        statement.setArray(column,
                connection.createArrayOf(elementSqlTypeName, elements));
    }

    @Override
    public Object typeCast(final Object value) throws TypeCastException
    {
        return value == null ? null : value.toString();
    }

    /**
     * Returns the sql type name this instance handles.
     *
     * @return The array sql type name this instance was constructed with,
     *         e.g. {@code "_int4"}.
     */
    public String getSqlTypeName()
    {
        return sqlTypeName;
    }

    /**
     * Splits a PostgreSQL array literal, such as {@code {1,2,3}} or
     * {@code {"a","b",NULL}}, into its top-level elements.
     *
     * @param literal
     *            The array literal text to split.
     * @return The literal's elements, in order; a {@code null} entry marks
     *         the unquoted {@code NULL} keyword.
     * @throws TypeCastException
     *             if the literal is not enclosed in {@code { }} or contains
     *             a nested array.
     */
    private String[] parseElements(final String literal)
            throws TypeCastException
    {
        logger.debug("parseElements(literal={}) - start", literal);

        final String trimmed = literal.trim();
        if (trimmed.length() < 2 || trimmed.charAt(0) != '{'
                || trimmed.charAt(trimmed.length() - 1) != '}')
        {
            throw new TypeCastException(literal, this);
        }

        final String body = trimmed.substring(1, trimmed.length() - 1);
        if (body.isEmpty())
        {
            return new String[0];
        }

        final List<String> elements = new ArrayList<>();
        final StringBuilder token = new StringBuilder();
        boolean inQuotes = false;
        boolean quoted = false;
        for (int i = 0; i < body.length(); i++)
        {
            final char c = body.charAt(i);
            if (inQuotes)
            {
                if (c == '\\' && i + 1 < body.length())
                {
                    token.append(body.charAt(++i));
                } else if (c == '"')
                {
                    inQuotes = false;
                } else
                {
                    token.append(c);
                }
            } else if (c == '"')
            {
                inQuotes = true;
                quoted = true;
            } else if (c == '\\' && i + 1 < body.length())
            {
                token.append(body.charAt(++i));
                quoted = true;
            } else if (c == '{')
            {
                throw new TypeCastException(literal, this);
            } else if (c == ',')
            {
                elements.add(toElement(token, quoted));
                token.setLength(0);
                quoted = false;
            } else
            {
                token.append(c);
            }
        }
        elements.add(toElement(token, quoted));

        return elements.toArray(new String[0]);
    }

    private static String toElement(final StringBuilder token,
            final boolean quoted)
    {
        if (quoted)
        {
            return token.toString();
        }

        final String trimmed = token.toString().trim();
        return "NULL".equalsIgnoreCase(trimmed) ? null : trimmed;
    }
}