Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/changes/changes.xml
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,9 @@
<action dev="jeffjensen" type="add" issue="752" system="github" due-to="jeffjensen">Add JsonDataSet, JsonProducer, and JsonWriter for reading and writing JSON-format datasets, including Ant &lt;export&gt;/&lt;operation&gt;/&lt;compare&gt; task support via a new "json" format; null column values are omitted on write and resolved from missing keys on read.</action>
<!-- dependabot:dep=com.fasterxml.jackson.core:jackson-databind:new=2.18.9:pr=914 -->
<action dev="dependabot" type="update" due-to="Dependabot">Update maven dependency com.fasterxml.jackson.core:jackson-databind from 2.18.2 to 2.18.9 (#914).</action>
<action dev="jeffjensen" type="update" issue="512" system="github" due-to="slandelle">
Reduce FlatXmlProducer memory footprint: column names built from SAX attribute names, previously a fresh String per occurrence even when identical to one already seen elsewhere in the document, now flow through a per-parse cache so repeated column names across rows and tables share one String instance instead of each Column retaining its own duplicate copy. Attribute values are left untouched since they are far less likely to repeat.
</action>
</release>
<release version="3.4.0" date="Jul 28, 2026" description="Test-suite hardening (un-skip and strengthen dozens of disabled/no-op tests); add CachingConnectionProvider and reduce DefaultPrepAndExpectedTestCase's per-test connection churn; pin identifier case-folding to Locale.ENGLISH for Turkish-locale correctness; and a broad set of correctness fixes across export formats (XML, YAML, CSV, XLS, Ant), TimestampDataType timezone handling, InsertOperation/TransactionOperation, and resource-leak cleanups">
<action dev="jeffjensen" type="fix" issue="797" system="github" due-to="jeffjensen">
Expand Down
59 changes: 54 additions & 5 deletions src/main/java/org/dbunit/dataset/xml/FlatXmlProducer.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@
import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;

import javax.xml.parsers.ParserConfigurationException;
Expand Down Expand Up @@ -120,7 +122,19 @@ public class FlatXmlProducer extends DefaultHandler implements IDataSetProducer,
*/
private Set _activeColumnNamesUpperCase;


/**
* Canonical instances of column names built from SAX attribute names, keyed by
* themselves. Column names repeat across many rows of a table and often across
* different tables, but the SAX parser hands back a fresh String for each
* occurrence; routing every {@link Column} construction through this cache lets
* those repeats share one instance instead of each being retained separately for
* the life of the parsed dataset. Live only for the duration of a single
* {@link #produce()} call: (re)created at its start and cleared at its end, since
* the cache itself serves no purpose once the columns it produced are handed off
* to the consumer.
*/
private Map<String, String> _columnNameCache;

/**
* Creates a producer that reads the given XML source, with DTD metadata enabled.
*
Expand Down Expand Up @@ -237,13 +251,32 @@ private ITableMetaData createTableMetaData(String tableName, Attributes attribut
Column[] columns = new Column[attributes.getLength()];
for (int i = 0; i < attributes.getLength(); i++)
{
columns[i] = new Column(attributes.getQName(i), DataType.UNKNOWN);
String columnName = internColumnName(attributes.getQName(i));
columns[i] = new Column(columnName, DataType.UNKNOWN);
}

return new DefaultTableMetaData(tableName, columns);
}



/**
* Returns a canonical String instance equal to the given column name, reusing a
* previously-cached instance when this parse has already seen that name.
*
* @param columnName The column name to canonicalize.
* @return A canonical String instance equal to columnName.
*/
private String internColumnName(String columnName)
{
String cachedName = _columnNameCache.get(columnName);
if (cachedName != null)
{
return cachedName;
}

_columnNameCache.put(columnName, columnName);
return columnName;
}

/**
* merges the existing columns with the potentially new ones.
* @param columnsToMerge List of extra columns found, which need to be merge back into the metadata.
Expand Down Expand Up @@ -345,7 +378,15 @@ protected void handleMissingColumns(Attributes attributes)
{
if (!_activeColumnNamesUpperCase.contains(attributes.getQName(i).toUpperCase(Locale.ENGLISH)))
{
columnsToMerge.add(new Column(attributes.getQName(i), DataType.UNKNOWN));
// Only canonicalize a name that will actually be retained in metadata: when
// column sensing is disabled, this Column is discarded right after the warning
// below is built, and caching its name would just grow the cache for nothing.
String columnName = attributes.getQName(i);
if (_columnSensing)
{
columnName = internColumnName(columnName);
}
columnsToMerge.add(new Column(columnName, DataType.UNKNOWN));
}
}

Expand Down Expand Up @@ -418,6 +459,8 @@ public void produce() throws DataSetException
{
logger.debug("produce() - start");

_columnNameCache = new HashMap<String, String>();

try
{
SAXParserFactory saxParserFactory = SAXParserFactory.newInstance();
Expand Down Expand Up @@ -448,6 +491,12 @@ public void produce() throws DataSetException
{
throw new DataSetException(e);
}
finally
{
// Only needed for the duration of a single parse; drop it immediately after
// instead of keeping it alive for as long as this producer instance is.
_columnNameCache = null;
}
}

////////////////////////////////////////////////////////////////////////////
Expand Down
99 changes: 99 additions & 0 deletions src/test/java/org/dbunit/dataset/xml/FlatXmlProducerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,24 @@
*/
package org.dbunit.dataset.xml;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.io.File;
import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.List;

import org.dbunit.TurkishDefaultLocale;
import org.dbunit.dataset.Column;
import org.dbunit.dataset.DataSetException;
import org.dbunit.dataset.DefaultDataSet;
import org.dbunit.dataset.DefaultTable;
import org.dbunit.dataset.ITableMetaData;
import org.dbunit.dataset.datatype.DataType;
import org.dbunit.dataset.stream.AbstractProducerTest;
import org.dbunit.dataset.stream.DefaultConsumer;
import org.dbunit.dataset.stream.IDataSetProducer;
import org.dbunit.dataset.stream.MockDataSetConsumer;
import org.dbunit.testutil.TestUtils;
Expand Down Expand Up @@ -305,4 +310,98 @@ void testProduce_turkishLocaleAndCaseVariantColumnName_recognizesSameColumn() th
consumer.verify();
}

@Test
void testProduce_sameColumnNameAcrossTables_reusesColumnNameStringInstance() throws Exception
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
{
// Two distinct tables sharing a column name, and no DTD/metaDataSet, so both
// tables' metadata is built straight from SAX attribute names.
final String content = "<?xml version=\"1.0\"?>" + "<dataset>"
+ "<TABLE_A COL0=\"a0\"/>" + "<TABLE_B COL0=\"b0\"/>"
+ "</dataset>";
final InputSource source = new InputSource(new StringReader(content));
final IDataSetProducer producer = new FlatXmlProducer(source);

final List<ITableMetaData> capturedMetaData = new ArrayList<>();
producer.setConsumer(new DefaultConsumer()
{
@Override
public void startTable(final ITableMetaData metaData) throws DataSetException
{
capturedMetaData.add(metaData);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

producer.produce();

final String tableAColumnName =
capturedMetaData.get(0).getColumns()[0].getColumnName();
final String tableBColumnName =
capturedMetaData.get(1).getColumns()[0].getColumnName();

assertThat(tableBColumnName)
.as("FlatXmlProducer should reuse one column-name String instance "
+ "across tables sharing a column name instead of allocating "
+ "a duplicate copy per table.")
.isSameAs(tableAColumnName);
}

@Test
void testProduce_columnSensedNameMatchesLaterTable_reusesColumnNameStringInstance() throws Exception
{
// TABLE_A senses COL_SHARED via handleMissingColumns, introduced only on its
// second row; TABLE_B declares COL_SHARED directly in its first row, so its
// metadata comes from createTableMetaData instead. Column sensing must be
// enabled, or TABLE_A's second row would just log a warning and drop COL_SHARED.
final String content = "<?xml version=\"1.0\"?>" + "<dataset>"
+ "<TABLE_A COL0=\"a0\"/>"
+ "<TABLE_A COL0=\"a0b\" COL_SHARED=\"a1\"/>"
+ "<TABLE_B COL_SHARED=\"b0\"/>" + "</dataset>";
final InputSource source = new InputSource(new StringReader(content));
final IDataSetProducer producer =
new FlatXmlProducer(source, false, true);

final List<ITableMetaData> capturedMetaData = new ArrayList<>();
producer.setConsumer(new DefaultConsumer()
{
@Override
public void startTable(final ITableMetaData metaData) throws DataSetException
{
capturedMetaData.add(metaData);
}
});

producer.produce();

final ITableMetaData tableAMetaData =
findTableMetaData(capturedMetaData, "TABLE_A");
final ITableMetaData tableBMetaData =
findTableMetaData(capturedMetaData, "TABLE_B");

final int sensedColumnIndex = tableAMetaData.getColumnIndex("COL_SHARED");
final int declaredColumnIndex = tableBMetaData.getColumnIndex("COL_SHARED");
final String sensedColumnName =
tableAMetaData.getColumns()[sensedColumnIndex].getColumnName();
final String declaredColumnName =
tableBMetaData.getColumns()[declaredColumnIndex].getColumnName();

assertThat(sensedColumnName)
.as("FlatXmlProducer should reuse the same column-name String "
+ "instance whether a column's metadata comes from column "
+ "sensing or from a later table's first row.")
.isSameAs(declaredColumnName);
}

private static ITableMetaData findTableMetaData(
final List<ITableMetaData> capturedMetaData, final String tableName)
{
for (final ITableMetaData metaData : capturedMetaData)
{
if (metaData.getTableName().equals(tableName))
{
return metaData;
}
}
throw new AssertionError("No captured metadata for table " + tableName + ".");
}

}
Loading