diff --git a/CLAUDE.md b/CLAUDE.md index 0d3c103a..a264168b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -The **dbUnit Eclipse Plugin** adds a spreadsheet-like editor for [dbUnit](https://github.com/dbunit/dbunit-extension) dataset files to the Eclipse IDE. Version 1 supports the flat XML format (`FlatXmlDataSet`). It is built with Maven and Eclipse Tycho and published as a PGP-signed p2 repository (Eclipse update site) on GitHub Pages. +The **dbUnit Eclipse Plugin** adds a spreadsheet-like editor for [dbUnit](https://github.com/dbunit/dbunit-extension) dataset files to the Eclipse IDE. The plugin currently supports the dbUnit flat XML format (`FlatXmlDataSet`); other dataset formats are planned. It is built with Maven and Eclipse Tycho and published as a PGP-signed p2 repository (Eclipse update site) on GitHub Pages. ## Build Commands @@ -14,6 +14,11 @@ The project uses a Maven wrapper. Always use `./mvnw` instead of `mvn`. Tycho 5 # Build, run all tests, and assemble the update site (default goal is verify) ./mvnw clean verify +# The same build with the current UTC time as the version qualifier, to install it into Eclipse: the pom pins +# project.build.outputTimestamp, so a plain build always has the same version, which Eclipse does not offer as an update. +# Arguments go to ./mvnw (none runs clean install); build-local.cmd is the Windows script. +./build-local.sh clean verify + # Build and test against the newest Eclipse release instead of the baseline (oldest supported) ./mvnw clean verify -Platest-target @@ -28,6 +33,9 @@ The project uses a Maven wrapper. Always use `./mvnw` instead of `mvn`. Tycho 5 # Skip tests ./mvnw clean verify -DskipTests + +# Skip SpotBugs, the Error Prone pass, and PMD, which verify runs on the core and UI bundles, failing the build on any finding +./mvnw clean verify -Dspotbugs.skip -Dmaven.main.skip -Dpmd.skip=true ``` UI tests (`bundles/org.dbunit.eclipse.dataset.ui/src/test/java`) run inside an Eclipse workbench via `tycho-surefire-plugin:plugin-test`; on Linux without a display, prefix the command with `xvfb-run -a`. @@ -93,7 +101,9 @@ The IDocument (text) is the single source of truth: grid edits become minimal te - Use Eclipse code cleanup settings file `code-cleanup-eclipse.xml` when modifying or creating files (e.g., `final` parameters, locals, and private fields). - Start every Java file with the dbUnit LGPL license header used by the existing sources. - Place the Logger variable first in the class; log through Eclipse's `org.eclipse.core.runtime.ILog` (`private static final ILog LOG = ILog.of(ClassName.class);`), not a logging framework. + - The core bundle's unit tests run without OSGi, where `ILog.of` throws, so core classes never call it: they take a `Consumer` log sink through their constructor, as `FlatXmlDatasetDocument` does, and the UI bundle passes `LOG::log`. - Write JavaDoc comments on all public classes and methods in src/main. + - A method that overrides or implements another one inherits the JavaDoc of that method, so it needs its own only when it says something that the inherited one does not. - In JavaDoc, use complete sentences for all descriptions, start with a capital letter and end with a period, for everything - the topic body, parameters, and return, including all annotations such as @param and @throws. - Tests: - Prefer assertJ. @@ -119,6 +129,11 @@ The IDocument (text) is the single source of truth: grid edits become minimal te - Monitor a branch's CI build result at for issues to correct. - Keep `org.dbunit.eclipse.dataset.core` free of UI dependencies (no `org.eclipse.ui*`, `org.eclipse.swt`, `org.eclipse.jface` other than `org.eclipse.jface.text` types from the `org.eclipse.text` bundle). - All SWT access happens on the UI thread; dispose every Color, Font, Image, and Cursor you create (prefer `JFaceResources`/`LocalResourceManager`). + - Fix static analysis findings instead of suppressing them. + - Exclude a SpotBugs finding that is by design only in `src/code-audit-rules/spotbugs-exclude.xml`, naming the class, the field, and the pattern, with the reason. + - Suppress an Error Prone finding that is by design with `@SuppressWarnings("CheckName")` on the smallest element, with a comment that gives the reason. + - Suppress a PMD finding that is by design with `@SuppressWarnings("PMD.RuleName")` on the smallest element, with a comment that gives the reason. + - Tycho compiles with the Eclipse compiler, which cannot run Error Prone, so Error Prone runs as a second, analysis-only javac pass (the `error-prone` execution of `maven-compiler-plugin` in the root `pom.xml`); the shipped classes still come from the Eclipse compiler. ## Troubleshooting diff --git a/README.adoc b/README.adoc index 683687ec..614601eb 100644 --- a/README.adoc +++ b/README.adoc @@ -7,12 +7,20 @@ image:https://img.shields.io/github/issues/dbunit/dbunit-eclipse-plugin["Open Is Edit link:https://dbunit.github.io/dbunit-extension/[dbUnit] dataset files like a spreadsheet, inside the Eclipse IDE. -The *dbUnit Dataset Editor* opens dbUnit flat XML dataset files as a grid: +The *dbUnit Dataset Editor* opens dbUnit dataset files as a grid: each table is a sheet, each row element is a spreadsheet row, and each attribute is a cell. -Edits are written back into the XML text with minimal changes, -so comments, formatting, and the DOCTYPE are preserved and diffs stay small. -Version 1 supports the dbUnit flat XML format; other dataset formats are planned. +The plugin currently supports the dbUnit flat XML format; other dataset formats are planned. + +== Features + +* link:https://dbunit.github.io/dbunit-eclipse-plugin/usage.html#tables-page[One sheet per table], with rows in file order +* link:https://dbunit.github.io/dbunit-eclipse-plugin/usage.html#editing[Spreadsheet editing], with link:https://dbunit.github.io/dbunit-eclipse-plugin/usage.html#null-values[NULL and empty strings kept apart] +* link:https://dbunit.github.io/dbunit-eclipse-plugin/usage.html#rows-columns-tables[Row, column, and table commands] +* link:https://dbunit.github.io/dbunit-eclipse-plugin/usage.html#clipboard[Copy and paste] to and from spreadsheet applications +* link:https://dbunit.github.io/dbunit-eclipse-plugin/usage.html#undo[Undo and redo] shared with the link:https://dbunit.github.io/dbunit-eclipse-plugin/usage.html#source-page[Source page] +* link:https://dbunit.github.io/dbunit-eclipse-plugin/usage.html#problems[Warnings about content dbUnit ignores or rejects] +* link:https://dbunit.github.io/dbunit-eclipse-plugin/usage.html#dtd[DTD support] and a link:https://dbunit.github.io/dbunit-eclipse-plugin/usage.html#opening[new dataset wizard] == Documentation diff --git a/build-local.cmd b/build-local.cmd new file mode 100644 index 00000000..6ce9df35 --- /dev/null +++ b/build-local.cmd @@ -0,0 +1,33 @@ +@echo off +rem Builds the plugin with the current UTC time as the version qualifier, to install the build into +rem Eclipse during development. build-local.sh does the same in bash. +rem +rem A plain mvnw build takes the qualifier from project.build.outputTimestamp in the root pom.xml, +rem which changes only per release, so every local build has the same version, and Eclipse does not +rem offer a rebuilt plugin as an update to the installed one. A build from this script is newer than +rem the one before it. The qualifier has the format of the pom's 'v'yyyyMMdd-HHmm, as in the CI build. +rem +rem Usage: build-local.cmd [Maven arguments] +rem Without arguments it runs: clean install +rem Arguments replace those goals, so give the goals too: build-local.cmd clean verify -DskipTests + +setlocal +pushd "%~dp0" + +set "BUILD_QUALIFIER=" +for /f "usebackq delims=" %%q in (`powershell -NoProfile -NonInteractive -Command "'v' + [DateTime]::UtcNow.ToString('yyyyMMdd-HHmm')"`) do set "BUILD_QUALIFIER=%%q" +if not defined BUILD_QUALIFIER ( + echo Cannot get the current UTC time from powershell for the version qualifier.>&2 + popd + exit /b 1 +) + +set "DEFAULT_GOALS=" +if "%~1"=="" set "DEFAULT_GOALS=clean install" + +echo Building with the version qualifier %BUILD_QUALIFIER% +call "%~dp0mvnw.cmd" -DforceContextQualifier=%BUILD_QUALIFIER% %DEFAULT_GOALS% %* +set "MAVEN_EXIT_CODE=%ERRORLEVEL%" + +popd +exit /b %MAVEN_EXIT_CODE% diff --git a/build-local.sh b/build-local.sh new file mode 100755 index 00000000..87412100 --- /dev/null +++ b/build-local.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# +# Builds the plugin with the current UTC time as the version qualifier, to install the build into +# Eclipse during development. build-local.cmd does the same on Windows. +# +# A plain mvnw build takes the qualifier from project.build.outputTimestamp in the root pom.xml, +# which changes only per release, so every local build has the same version, and Eclipse does not +# offer a rebuilt plugin as an update to the installed one. A build from this script is newer than +# the one before it. The qualifier has the format of the pom's 'v'yyyyMMdd-HHmm, as in the CI build. +# +# Usage: build-local.sh [] +# Without arguments it runs: clean install +# Arguments replace those goals, so give the goals too: build-local.sh clean verify -DskipTests + +set -euo pipefail + +cd "$(dirname "$0")" + +if [ "$#" -eq 0 ]; then + set -- clean install +fi + +qualifier="v$(date -u +%Y%m%d-%H%M)" +echo "Building with the version qualifier ${qualifier}" + +exec ./mvnw -DforceContextQualifier="${qualifier}" "$@" diff --git a/bundles/org.dbunit.eclipse.dataset.core/META-INF/MANIFEST.MF b/bundles/org.dbunit.eclipse.dataset.core/META-INF/MANIFEST.MF index ad4b87da..20ae2673 100644 --- a/bundles/org.dbunit.eclipse.dataset.core/META-INF/MANIFEST.MF +++ b/bundles/org.dbunit.eclipse.dataset.core/META-INF/MANIFEST.MF @@ -10,5 +10,15 @@ Bundle-ActivationPolicy: lazy Automatic-Module-Name: org.dbunit.eclipse.dataset.core Require-Bundle: org.eclipse.equinox.common;bundle-version="3.19.0", org.eclipse.core.contenttype;bundle-version="3.9.0", - org.eclipse.text;bundle-version="3.14.0" -Export-Package: org.dbunit.eclipse.dataset.core + org.eclipse.text;bundle-version="3.14.0", + org.eclipse.core.commands;bundle-version="3.12.0" +Import-Package: javax.xml.parsers, + org.eclipse.osgi.util;version="1.1.0", + org.xml.sax, + org.xml.sax.helpers +Export-Package: org.dbunit.eclipse.dataset.core, + org.dbunit.eclipse.dataset.core.model, + org.dbunit.eclipse.dataset.core.dtd;x-friends:="org.dbunit.eclipse.dataset.ui", + org.dbunit.eclipse.dataset.core.edit;x-friends:="org.dbunit.eclipse.dataset.ui", + org.dbunit.eclipse.dataset.core.flatxml;x-friends:="org.dbunit.eclipse.dataset.ui", + org.dbunit.eclipse.dataset.core.tsv;x-friends:="org.dbunit.eclipse.dataset.ui" diff --git a/bundles/org.dbunit.eclipse.dataset.core/build.properties b/bundles/org.dbunit.eclipse.dataset.core/build.properties index c1a49c55..7b5f35f1 100644 --- a/bundles/org.dbunit.eclipse.dataset.core/build.properties +++ b/bundles/org.dbunit.eclipse.dataset.core/build.properties @@ -2,4 +2,5 @@ source.. = src/main/java/ output.. = target/classes/ bin.includes = META-INF/,\ .,\ - plugin.properties + plugin.properties,\ + plugin.xml diff --git a/bundles/org.dbunit.eclipse.dataset.core/plugin.properties b/bundles/org.dbunit.eclipse.dataset.core/plugin.properties index 73f30724..924dd339 100644 --- a/bundles/org.dbunit.eclipse.dataset.core/plugin.properties +++ b/bundles/org.dbunit.eclipse.dataset.core/plugin.properties @@ -1,2 +1,3 @@ Bundle-Name = dbUnit Dataset Core Bundle-Vendor = dbUnit +contentType.flatXml.name = dbUnit Flat XML Dataset diff --git a/bundles/org.dbunit.eclipse.dataset.core/plugin.xml b/bundles/org.dbunit.eclipse.dataset.core/plugin.xml new file mode 100644 index 00000000..7dfee04d --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/plugin.xml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/pom.xml b/bundles/org.dbunit.eclipse.dataset.core/pom.xml index 96d266a5..586ad19d 100644 --- a/bundles/org.dbunit.eclipse.dataset.core/pom.xml +++ b/bundles/org.dbunit.eclipse.dataset.core/pom.xml @@ -78,6 +78,18 @@ true + + com.github.spotbugs + spotbugs-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.maven.plugins + maven-pmd-plugin + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/Messages.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/Messages.java new file mode 100644 index 00000000..6878a3ab --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/Messages.java @@ -0,0 +1,479 @@ +/* + * + * 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.eclipse.dataset.core; + +import org.eclipse.osgi.util.NLS; + +/** + * Externalized, user-visible strings of the dataset core bundle. + * + * @since 1.0.0 + */ +// NLS binds each field to the message key of the same name, so the field names follow the keys. +@SuppressWarnings("PMD.FieldNamingConventions") +public final class Messages extends NLS +{ + private static final String BUNDLE_NAME = "org.dbunit.eclipse.dataset.core.messages"; + + /** + * A column has a value in a later row but is missing from the table's first element. + */ + public static String Validator_columnNotInFirstRow; + + /** + * The table's first element has no attributes, but a later row has values. + */ + public static String Validator_firstElementWithoutAttributes; + + /** + * A table is spelled with more than one letter case. + */ + public static String Validator_tableNameCaseVariants; + + /** + * A column of a table is spelled with more than one letter case. + */ + public static String Validator_columnNameCaseVariants; + + /** + * An element has two attributes whose names differ only in case. + */ + public static String Validator_duplicateColumnInRow; + + /** + * A table that has elements is not declared in the DTD. + */ + public static String Validator_tableNotDeclaredInDtd; + + /** + * A row has an attribute the DTD does not declare for the table, without column sensing. + */ + public static String Validator_columnNotDeclaredInDtdWarning; + + /** + * A row has an attribute the DTD does not declare for the table, with column sensing. + */ + public static String Validator_columnNotDeclaredInDtdError; + + /** + * The DTD content model names an element the DTD does not declare. + */ + public static String Validator_dtdTableWithoutDeclaration; + + /** + * The DTD declares the dataset element as EMPTY, which dbUnit cannot load. + */ + public static String Validator_dtdEmptyContentModel; + + /** + * The DTD lists two spellings of one table, which dbUnit cannot load. + */ + public static String Validator_dtdTableNameCaseVariants; + + /** + * The DOCTYPE's external DTD could not be read. + */ + public static String Validator_dtdNotLoaded; + + /** + * An empty element in a table that has rows, other than the table's first element. + */ + public static String Validator_redundantEmptyElement; + + /** + * An edit is rejected because the source has errors that block editing. + */ + public static String Edit_sourceHasErrors; + + /** + * An edit names a table that does not exist; {0} is the table key. + */ + public static String Edit_noSuchTable; + + /** + * A cell change names a row that does not exist; {0} is the row index, {1} the table. + */ + public static String Edit_noSuchRow; + + /** + * An edit names a column that does not exist; {0} is the column, {1} the table. + */ + public static String Edit_noSuchColumn; + + /** + * A cell change would leave a row without values; {0} is the row index, {1} the table. + */ + public static String Edit_rowWouldBeEmpty; + + /** + * An edit would take a column off the first row of a table while other rows have values for it, so + * dbUnit would ignore them; {0} is the column, {1} the table. + */ + public static String Edit_firstRowWouldLoseColumn; + + /** + * A cell cannot be changed while its row has two attributes for the column; {0} is the column, {1} the + * row index, {2} the table. + */ + public static String Edit_cellHasCaseVariantAttributes; + + /** + * Rows cannot be inserted into a table without columns; {0} is the table. + */ + public static String Edit_tableHasNoColumns; + + /** + * A row index is out of range; {0} is the row index, {1} the table. + */ + public static String Edit_rowOutOfRange; + + /** + * A new row has the wrong number of values; {0} is the number of columns, {1} the table. + */ + public static String Edit_wrongValueCount; + + /** + * A new row would have no values; {0} is the table. + */ + public static String Edit_newRowWouldBeEmpty; + + /** + * A block of rows to move is out of range; {0} is the table. + */ + public static String Edit_rowBlockOutOfRange; + + /** + * A block of rows can move only one position at a time. + */ + public static String Edit_moveByOnePosition; + + /** + * A block of rows cannot move above the first row; {0} is the table. + */ + public static String Edit_moveBeforeFirstRow; + + /** + * A block of rows cannot move below the last row; {0} is the table. + */ + public static String Edit_moveAfterLastRow; + + /** + * A column name is not an XML name; {0} is the name. + */ + public static String Edit_invalidColumnName; + + /** + * A table already has a column of that name; {0} is the table, {1} the column. + */ + public static String Edit_columnExists; + + /** + * A column cannot be renamed while a row has two attributes for it; {0} is the column, {1} the table. + */ + public static String Edit_renameColumnWithCaseVariants; + + /** + * A column cannot be deleted because a row would have no values left; {0} is the column, {1} the table. + */ + public static String Edit_deleteColumnWouldEmptyRow; + + /** + * A column cannot be renamed because the DTD declares it but no row has a value for it; {0} is the + * column, {1} the table. + */ + public static String Edit_renameColumnIsDeclaredOnly; + + /** + * A table name is not an XML name; {0} is the name. + */ + public static String Edit_invalidTableName; + + /** + * A table name is the name of the root element; {0} is the name. + */ + public static String Edit_reservedTableName; + + /** + * A table of that name already exists; {0} is the name. + */ + public static String Edit_tableExists; + + /** + * A table cannot be renamed because it exists only in the DTD; {0} is the table. + */ + public static String Edit_renameTableIsDeclaredOnly; + + /** + * A table cannot be deleted because it exists only in the DTD; {0} is the table. + */ + public static String Edit_deleteTableIsDeclaredOnly; + + /** + * An empty dataset can only be created in a blank document. + */ + public static String Edit_documentNotBlank; + + /** + * A value has a character that XML 1.0 does not allow; {0} is its code point in hexadecimal. + */ + public static String Codec_notXmlCharacter; + + /** + * An ampersand in an attribute value does not start a reference. + */ + public static String Codec_invalidAmpersand; + + /** + * An entity reference other than the predefined ones; {0} is the entity name. + */ + public static String Codec_unsupportedEntity; + + /** + * A character reference without digits. + */ + public static String Codec_referenceWithoutDigits; + + /** + * A character reference with invalid digits; {0} is the digits. + */ + public static String Codec_invalidReference; + + /** + * A character reference beyond the last Unicode code point. + */ + public static String Codec_referenceTooLarge; + + /** + * A character reference to a character that XML 1.0 does not allow; {0} is its code point in + * hexadecimal. + */ + public static String Codec_referenceNotXmlCharacter; + + /** + * A parser message with the position it refers to; {0} is the message, {1} the line, {2} the column. + */ + public static String Parser_position; + + /** + * The document has no root element. + */ + public static String Parser_noRootElement; + + /** + * A character that is not markup precedes the root element. + */ + public static String Parser_unexpectedBeforeRoot; + + /** + * The root element is not named dataset; {0} is its name. + */ + public static String Parser_rootNotDataset; + + /** + * Content other than comments and processing instructions follows the root element. + */ + public static String Parser_unexpectedAfterRoot; + + /** + * The document ends inside a start tag. + */ + public static String Parser_endedInStartTag; + + /** + * An element name is not followed by whitespace, '/>', or '>'. + */ + public static String Parser_expectedAfterElementName; + + /** + * Two attributes are not separated by whitespace. + */ + public static String Parser_expectedWhitespaceBetweenAttributes; + + /** + * An element repeats an attribute; {0} is the attribute. + */ + public static String Parser_duplicateAttribute; + + /** + * An attribute name is not followed by '='. + */ + public static String Parser_expectedEquals; + + /** + * An attribute's '=' is not followed by a quoted value. + */ + public static String Parser_expectedQuotedValue; + + /** + * An attribute value contains '<'. + */ + public static String Parser_lessThanInValue; + + /** + * An attribute value has no closing quote. + */ + public static String Parser_unclosedValue; + + /** + * The document ends before an element's end tag; {0} is the element name. + */ + public static String Parser_endedBeforeEndTag; + + /** + * An element is nested inside a row element. + */ + public static String Parser_nestedElement; + + /** + * An end tag has no closing '>'; {0} is the element name. + */ + public static String Parser_unclosedEndTag; + + /** + * An end tag does not match its start tag; {0} is the expected name, {1} the found one. + */ + public static String Parser_mismatchedEndTag; + + /** + * Text between elements, which dbUnit ignores. + */ + public static String Parser_textIgnored; + + /** + * A DOCTYPE lacks the root name. + */ + public static String Parser_expectedDoctypeName; + + /** + * A DOCTYPE has no closing '>'. + */ + public static String Parser_unclosedDoctype; + + /** + * A DOCTYPE has no whitespace between SYSTEM or PUBLIC and the quoted literal that follows it. + */ + public static String Parser_expectedWhitespaceAfterIdKeyword; + + /** + * A DOCTYPE has no whitespace between its public literal and its system literal. + */ + public static String Parser_expectedWhitespaceBetweenIds; + + /** + * A DOCTYPE lacks a quoted literal. + */ + public static String Parser_expectedQuotedLiteral; + + /** + * A DOCTYPE literal has no closing quote. + */ + public static String Parser_unclosedLiteral; + + /** + * A literal in a DOCTYPE's internal subset has no closing quote. + */ + public static String Parser_unclosedSubsetLiteral; + + /** + * A DOCTYPE's internal subset has no closing ']'. + */ + public static String Parser_unclosedSubset; + + /** + * A name is expected. + */ + public static String Parser_expectedName; + + /** + * A processing instruction has no closing '?>'. + */ + public static String Parser_unclosedProcessingInstruction; + + /** + * A comment has no closing '-->'. + */ + public static String Parser_unclosedComment; + + /** + * A comment holds two hyphens in a row. + */ + public static String Parser_doubleHyphenInComment; + + /** + * A processing instruction has no target name after '<?'. + */ + public static String Parser_expectedProcessingInstructionTarget; + + /** + * A processing instruction's target is reserved for the XML declaration; {0} is the target. + */ + public static String Parser_reservedProcessingInstructionTarget; + + /** + * A processing instruction's target is not followed by whitespace or '?>'. + */ + public static String Parser_expectedSpaceAfterProcessingInstructionTarget; + + /** + * A CDATA section has no closing ']]>'. + */ + public static String Parser_unclosedCdata; + + /** + * Text contains ']]>', which is allowed only as the end of a CDATA section. + */ + public static String Parser_cdataEndInText; + + /** + * Text refers to an entity that no DTD declares; {0} is the entity's name. + */ + public static String Parser_entityNotDeclared; + + /** + * A DTD's parameter entity declarations are ignored. + */ + public static String Dtd_parameterEntityDeclarationsIgnored; + + /** + * A DTD's conditional sections are ignored. + */ + public static String Dtd_conditionalSectionsIgnored; + + /** + * A DTD's parameter entity references are ignored. + */ + public static String Dtd_parameterEntityReferencesIgnored; + + /** + * An attribute's default value in a DTD cannot be decoded, so it is ignored; {0} is the attribute's + * name, {1} the element's name, and {2} why the value cannot be decoded. + */ + public static String Dtd_attributeDefaultNotShown; + + static + { + NLS.initializeMessages(BUNDLE_NAME, Messages.class); + } + + private Messages() + { + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/dtd/DtdDeclarations.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/dtd/DtdDeclarations.java new file mode 100644 index 00000000..534012c2 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/dtd/DtdDeclarations.java @@ -0,0 +1,342 @@ +/* + * + * 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.eclipse.dataset.core.dtd; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.dbunit.eclipse.dataset.core.model.DatasetProblem; + +/** + * The declarations of a DTD relevant to a flat XML dataset: the {@code dataset} element's content model + * and each declared element's columns, in declaration order. + * + * @since 1.0.0 + */ +public final class DtdDeclarations +{ + private final boolean contentModelDeclared; + + private final boolean contentModelAny; + + private final boolean contentModelEmpty; + + private final List contentModelNames; + + private final Map> declaredElements; + + private final Map> attributeDefaults; + + private final List problems; + + private final Set externalElementNames; + + /** + * Creates DTD declarations that were not merged with those of an external DTD. + * + * @param contentModelDeclared True when the {@code dataset} element has an {@code ELEMENT} + * declaration. + * @param contentModelAny True when that content model is {@code ANY}, or holds a parameter entity + * reference, which can list any element and which the reader does not expand; + * meaningless otherwise. + * @param contentModelEmpty True when that content model is {@code EMPTY}; meaningless otherwise. + * @param contentModelNames The content model's names, in order; empty when contentModelAny is true + * or contentModelDeclared is false. + * @param declaredElements Each declared element's columns, in declaration order, keyed by element + * name in declaration order; copied defensively. + * @param attributeDefaults The default or {@code #FIXED} value of each declared column that has one, + * keyed by element name and then by column name; copied defensively. + * @param problems The problems found while reading, copied defensively. + */ + DtdDeclarations(final boolean contentModelDeclared, final boolean contentModelAny, + final boolean contentModelEmpty, final List contentModelNames, + final Map> declaredElements, + final Map> attributeDefaults, final List problems) + { + this(contentModelDeclared, contentModelAny, contentModelEmpty, contentModelNames, declaredElements, + attributeDefaults, problems, Set.of()); + } + + /** + * Creates DTD declarations. + * + * @param contentModelDeclared True when the {@code dataset} element has an {@code ELEMENT} + * declaration. + * @param contentModelAny True when that content model is {@code ANY}, or holds a parameter entity + * reference, which can list any element and which the reader does not expand; + * meaningless otherwise. + * @param contentModelEmpty True when that content model is {@code EMPTY}; meaningless otherwise. + * @param contentModelNames The content model's names, in order; empty when contentModelAny is true + * or contentModelDeclared is false. + * @param declaredElements Each declared element's columns, in declaration order, keyed by element + * name in declaration order; copied defensively. + * @param attributeDefaults The default or {@code #FIXED} value of each declared column that has one, + * keyed by element name and then by column name; copied defensively. + * @param problems The problems found while reading, copied defensively. + * @param externalElementNames The names of the elements that an external DTD, which these declarations + * were merged with, declares or lists in its content model; copied + * defensively. + */ + private DtdDeclarations(final boolean contentModelDeclared, final boolean contentModelAny, + final boolean contentModelEmpty, final List contentModelNames, + final Map> declaredElements, + final Map> attributeDefaults, final List problems, + final Set externalElementNames) + { + this.contentModelDeclared = contentModelDeclared; + this.contentModelAny = contentModelAny; + this.contentModelEmpty = contentModelEmpty; + this.contentModelNames = List.copyOf(contentModelNames); + final Map> copy = new LinkedHashMap<>(); + for (final Map.Entry> entry : declaredElements.entrySet()) + { + copy.put(entry.getKey(), List.copyOf(entry.getValue())); + } + this.declaredElements = copy; + final Map> defaultsCopy = new LinkedHashMap<>(); + for (final Map.Entry> entry : attributeDefaults.entrySet()) + { + defaultsCopy.put(entry.getKey(), Map.copyOf(entry.getValue())); + } + this.attributeDefaults = defaultsCopy; + this.problems = List.copyOf(problems); + this.externalElementNames = Set.copyOf(externalElementNames); + } + + /** + * Returns the problems found while reading the DTD. + * + * @return An unmodifiable list of problems. + */ + public List getProblems() + { + return problems; + } + + /** + * Returns the names of the elements that an external DTD declares, with an {@code ELEMENT} or + * {@code ATTLIST} declaration or by listing them in the content model of the {@code dataset} element. + * The editor does not change an external DTD, so these are the tables that it cannot rename in the DTD. + * + * @return An unmodifiable set of element names; empty when these declarations were not merged with + * those of an external DTD. + */ + public Set externalElementNames() + { + return externalElementNames; + } + + /** + * Returns a copy of these declarations with every problem's offset shifted by delta, its length + * unchanged, so a problem found while reading an internal subset can be relocated to where that + * subset sits in the surrounding document. + * + * @param delta The number of characters to add to each problem's offset. + * @return The relocated declarations. + */ + public DtdDeclarations withProblemsShiftedBy(final int delta) + { + final List relocated = new ArrayList<>(); + for (final DatasetProblem problem : problems) + { + relocated.add(new DatasetProblem(problem.code(), problem.severity(), problem.message(), + problem.tableKey(), problem.columnName(), problem.rowIndex(), + problem.offset() + delta, problem.length())); + } + return new DtdDeclarations(contentModelDeclared, contentModelAny, contentModelEmpty, + contentModelNames, declaredElements, attributeDefaults, relocated, externalElementNames); + } + + /** + * Returns a copy of these declarations with every problem's offset and length replaced by offset and + * length, so a problem found while reading an external DTD, which is not open in the editor, points + * at the DOCTYPE declaration instead of an arbitrary range of the dataset document. + * + * @param offset The new offset for every problem. + * @param length The new length for every problem. + * @return The relocated declarations. + */ + public DtdDeclarations withProblemsAt(final int offset, final int length) + { + final List relocated = new ArrayList<>(); + for (final DatasetProblem problem : problems) + { + relocated.add(new DatasetProblem(problem.code(), problem.severity(), problem.message(), + problem.tableKey(), problem.columnName(), problem.rowIndex(), offset, length)); + } + return new DtdDeclarations(contentModelDeclared, contentModelAny, contentModelEmpty, + contentModelNames, declaredElements, attributeDefaults, relocated, externalElementNames); + } + + /** + * Returns whether the {@code dataset} element is declared {@code EMPTY}. dbUnit cannot load such a + * dataset: it reads the table names from the content model's text without its first and last + * character, so it looks for a table named {@code MPT}. + * + * @return True when the content model is {@code EMPTY}. + */ + public boolean hasEmptyContentModel() + { + return contentModelDeclared && contentModelEmpty; + } + + /** + * Returns the DTD's tables, as dbUnit's {@code FlatDtdProducer} derives them: the content + * model's names in order, or, for {@code ANY} and for a content model with a parameter entity + * reference, which can list any element and which the reader does not expand, every declared element + * in declaration order. + * + * @return An unmodifiable list of tables, one per distinct name, each with the columns of its + * element and their default values. Empty when the {@code dataset} element has no content + * model. + */ + public List tables() + { + if (!contentModelDeclared) + { + return List.of(); + } + final List names = + contentModelAny ? List.copyOf(declaredElements.keySet()) : contentModelNames; + final Set seenNames = new HashSet<>(); + final List tables = new ArrayList<>(); + for (final String name : names) + { + if (!seenNames.add(name)) + { + continue; + } + tables.add(new DtdTable(name, declaredElements.getOrDefault(name, List.of()), + attributeDefaults.getOrDefault(name, Map.of()))); + } + return List.copyOf(tables); + } + + /** + * Returns the content model names that no {@code ELEMENT} or {@code ATTLIST} declares. + * + * @return An unmodifiable list, one per distinct name, in content model order. + */ + public List missingDeclarations() + { + if (!contentModelDeclared || contentModelAny) + { + return List.of(); + } + final Set seenNames = new HashSet<>(); + final List missing = new ArrayList<>(); + for (final String name : contentModelNames) + { + if (!seenNames.add(name)) + { + continue; + } + if (!declaredElements.containsKey(name)) + { + missing.add(name); + } + } + return List.copyOf(missing); + } + + /** + * Combines these declarations, read from a DOCTYPE's internal subset, with those of an external DTD, + * in the order XML parsers read them. + * + * @param later The declarations of the external DTD. + * @return The merged declarations: later's content model when it declares one, otherwise this one's, + * because dbUnit takes the last declaration of the root element; elements merged by name, + * appending later's columns after this's and skipping duplicates. A column + * declared in both keeps the default value of this one, as the first declaration of an + * attribute is binding. The elements that the external DTD declares are the merged + * declarations' {@link #externalElementNames()}. + */ + public DtdDeclarations merge(final DtdDeclarations later) + { + final boolean laterDeclares = later.contentModelDeclared; + final boolean mergedDeclared = contentModelDeclared || laterDeclares; + final boolean mergedAny = laterDeclares ? later.contentModelAny : contentModelAny; + final boolean mergedEmpty = laterDeclares ? later.contentModelEmpty : contentModelEmpty; + final List mergedNames = laterDeclares ? later.contentModelNames : contentModelNames; + final Map> mergedElements = new LinkedHashMap<>(); + final Map> mergedDefaults = new LinkedHashMap<>(); + addColumnsNotYetDeclared(mergedElements, mergedDefaults, this); + addColumnsNotYetDeclared(mergedElements, mergedDefaults, later); + final List mergedProblems = new ArrayList<>(problems); + mergedProblems.addAll(later.problems); + final Set mergedExternalNames = new LinkedHashSet<>(externalElementNames); + mergedExternalNames.addAll(later.declaredElementNames()); + return new DtdDeclarations(mergedDeclared, mergedAny, mergedEmpty, mergedNames, mergedElements, + mergedDefaults, mergedProblems, mergedExternalNames); + } + + /** + * Returns the names of the elements that these declarations declare with an {@code ELEMENT} or + * {@code ATTLIST} declaration or list in the content model of the {@code dataset} element. + */ + private Set declaredElementNames() + { + final Set names = new LinkedHashSet<>(declaredElements.keySet()); + names.addAll(contentModelNames); + return names; + } + + /** + * Adds a source's elements, their columns, and the columns' default values to the merged ones, except + * the columns the merged elements already have. + */ + private static void addColumnsNotYetDeclared(final Map> mergedElements, + final Map> mergedDefaults, final DtdDeclarations source) + { + for (final Map.Entry> entry : source.declaredElements.entrySet()) + { + final String elementName = entry.getKey(); + final List columns = + mergedElements.computeIfAbsent(elementName, key -> new ArrayList<>()); + final Map sourceDefaults = + source.attributeDefaults.getOrDefault(elementName, Map.of()); + for (final String column : entry.getValue()) + { + if (!columns.contains(column)) + { + columns.add(column); + addDefault(mergedDefaults, elementName, column, sourceDefaults.get(column)); + } + } + } + } + + private static void addDefault(final Map> mergedDefaults, + final String elementName, final String column, final String defaultValue) + { + if (defaultValue != null) + { + final Map elementDefaults = + mergedDefaults.computeIfAbsent(elementName, key -> new LinkedHashMap<>()); + elementDefaults.put(column, defaultValue); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/dtd/DtdElementName.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/dtd/DtdElementName.java new file mode 100644 index 00000000..161c73dc --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/dtd/DtdElementName.java @@ -0,0 +1,33 @@ +/* + * + * 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.eclipse.dataset.core.dtd; + +/** + * A place in DTD text that writes the name of an element: the name that an {@code ELEMENT} or + * {@code ATTLIST} declaration gives, or a name in the content model of the {@code dataset} element. + * + * @param name The element name, spelled as the text spells it. + * @param offset The offset of the name's first character in the DTD text. + * @since 1.0.0 + */ +public record DtdElementName(String name, int offset) +{ +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/dtd/DtdLexer.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/dtd/DtdLexer.java new file mode 100644 index 00000000..2a555329 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/dtd/DtdLexer.java @@ -0,0 +1,309 @@ +/* + * + * 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.eclipse.dataset.core.dtd; + +import org.dbunit.eclipse.dataset.core.flatxml.XmlNames; + +/** + * A cursor over DTD text with the character-level operations {@link DtdReader} needs: testing what comes + * next, skipping whitespace, comments, and processing instructions, and scanning names, bare words, and + * quoted literals. It knows nothing of what a declaration means and never fails: it stops at the end of + * the text. Each instance holds the position of one read. + */ +final class DtdLexer +{ + private final String dtdText; + + private final int length; + + private int pos; + + DtdLexer(final String dtdText) + { + this.dtdText = dtdText; + this.length = dtdText.length(); + } + + /** + * Returns the offset of the next character to read. + * + * @return The offset, from 0 up to the length of the text. + */ + int position() + { + return pos; + } + + /** + * Moves the position forward. + * + * @param count The number of characters to move over. + */ + void advance(final int count) + { + pos += count; + } + + /** + * Tells whether the whole text has been read. + * + * @return True when no character is left. + */ + boolean atEnd() + { + return pos >= length; + } + + boolean atCharacter(final char expected) + { + return pos < length && dtdText.charAt(pos) == expected; + } + + boolean atDeclarationEnd() + { + return pos >= length || dtdText.charAt(pos) == '>'; + } + + /** + * Tells whether the text continues with the given literal. + * + * @param literal The text to look for at the position. + * @return True when the literal starts at the position. + */ + boolean atText(final String literal) + { + return matchesAt(pos, literal); + } + + void skipWhitespace() + { + while (pos < length && XmlNames.isWhitespace(dtdText.charAt(pos))) + { + pos++; + } + } + + void skipCharacter(final char expected) + { + if (atCharacter(expected)) + { + pos++; + } + } + + void skipComment() + { + pos += "")) + { + pos++; + } + if (pos < length) + { + pos += "-->".length(); + } + } + + void skipProcessingInstruction() + { + pos += "")) + { + pos++; + } + if (pos < length) + { + pos += "?>".length(); + } + } + + void skipConditionalSection() + { + pos += "")) + { + pos++; + } + if (pos < length) + { + pos += "]]>".length(); + } + } + + void skipParameterEntityReference() + { + pos++; // consume '%' + scanName(); + if (pos < length && dtdText.charAt(pos) == ';') + { + pos++; + } + } + + void skipToMatchingCloseAngleBracket() + { + while (pos < length && dtdText.charAt(pos) != '>') + { + final char ch = dtdText.charAt(pos); + if (isQuote(ch)) + { + pos++; + while (pos < length && dtdText.charAt(pos) != ch) + { + pos++; + } + } + pos++; + } + if (pos < length) + { + pos++; // consume '>' + } + } + + void skipParenthesizedList() + { + while (pos < length && dtdText.charAt(pos) != ')') + { + pos++; + } + if (pos < length) + { + pos++; // consume ')' + } + } + + /** + * Scans a quoted literal when the text continues with a quote. + * + * @return The literal's text between the quotes, or null when the text does not continue with a quote. + */ + String scanQuotedLiteralIfPresent() + { + if (pos < length && isQuote(dtdText.charAt(pos))) + { + return scanQuotedLiteral(); + } + return null; + } + + /** + * Scans the quoted literal the text continues with. + * + * @return The literal's text between the quotes; a literal that is never closed ends at the end of the + * text. + */ + String scanQuotedLiteral() + { + final char quote = dtdText.charAt(pos); + pos++; + final int start = pos; + while (pos < length && dtdText.charAt(pos) != quote) + { + pos++; + } + final String literal = dtdText.substring(start, pos); + if (pos < length) + { + pos++; // consume the closing quote + } + return literal; + } + + String scanParenthesizedContentSpec() + { + final int start = pos; + int depth = 0; + while (pos < length) + { + final char ch = dtdText.charAt(pos); + pos++; + if (ch == '(') + { + depth++; + } + else if (ch == ')') + { + depth--; + if (depth == 0) + { + break; + } + } + } + if (pos < length && isQuantifier(dtdText.charAt(pos))) + { + pos++; + } + return dtdText.substring(start, pos); + } + + String scanName() + { + final int start = pos; + if (pos < length && XmlNames.isNameStartChar(Character.codePointAt(dtdText, pos))) + { + pos += Character.charCount(Character.codePointAt(dtdText, pos)); + while (pos < length && XmlNames.isNameChar(Character.codePointAt(dtdText, pos))) + { + pos += Character.charCount(Character.codePointAt(dtdText, pos)); + } + } + return dtdText.substring(start, pos); + } + + String scanBareWord() + { + final int start = pos; + while (pos < length && !XmlNames.isWhitespace(dtdText.charAt(pos)) + && dtdText.charAt(pos) != '>') + { + pos++; + } + return dtdText.substring(start, pos); + } + + private boolean matchesAt(final int position, final String literal) + { + if (position + literal.length() > length) + { + return false; + } + for (int i = 0; i < literal.length(); i++) + { + if (dtdText.charAt(position + i) != literal.charAt(i)) + { + return false; + } + } + return true; + } + + private static boolean isQuote(final char ch) + { + return ch == '"' || ch == '\''; + } + + private static boolean isQuantifier(final char ch) + { + return ch == '*' || ch == '?' || ch == '+'; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/dtd/DtdReader.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/dtd/DtdReader.java new file mode 100644 index 00000000..7b4e2fd8 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/dtd/DtdReader.java @@ -0,0 +1,474 @@ +/* + * + * 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.eclipse.dataset.core.dtd; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.dbunit.eclipse.dataset.core.Messages; +import org.dbunit.eclipse.dataset.core.flatxml.AttributeValueCodec; +import org.dbunit.eclipse.dataset.core.flatxml.AttributeValueException; +import org.dbunit.eclipse.dataset.core.flatxml.XmlNames; +import org.dbunit.eclipse.dataset.core.model.DatasetProblem; +import org.dbunit.eclipse.dataset.core.model.ProblemCode; +import org.dbunit.eclipse.dataset.core.model.ProblemSeverity; +import org.eclipse.osgi.util.NLS; + +/** + * Reads the {@code ELEMENT} and {@code ATTLIST} declarations of a DTD, serving both external DTD files + * and DOCTYPE internal subsets. DTD text is edited only to rename an element, so, unlike + * {@code FlatXmlParser}, this reader does not track the offsets of every declaration, only those of the + * element names (see {@link #locateElementNames}). + * + * @since 1.0.0 + */ +public final class DtdReader +{ + private static final String DEFAULT_ROOT_NAME = "dataset"; + + private DtdReader() + { + } + + /** + * Reads a DTD's declarations, for a document whose root element is {@code dataset}. + * + * @param dtdText The DTD text: an external DTD file's content, or a DOCTYPE's internal subset. + * @return The declarations found. + */ + public static DtdDeclarations read(final String dtdText) + { + return read(dtdText, DEFAULT_ROOT_NAME); + } + + /** + * Reads a DTD's declarations. The content model of the element that the DOCTYPE names lists the tables, + * as it does for dbUnit, which takes the name of the DOCTYPE for the name of the root. + * + * @param dtdText The DTD text: an external DTD file's content, or a DOCTYPE's internal subset. + * @param rootName The name that the DOCTYPE gives the root element. + * @return The declarations found. + */ + public static DtdDeclarations read(final String dtdText, final String rootName) + { + final Scanner scanner = new Scanner(new DtdLexer(dtdText), rootName); + scanner.scan(); + return scanner.declarations(); + } + + /** + * Finds where a DTD writes the names of its elements, for a document whose root element is + * {@code dataset}; see {@link #locateElementNames(String, String)}. + * + * @param dtdText The DTD text: an external DTD file's content, or a DOCTYPE's internal subset. + * @return The names, in the order the text writes them. + */ + public static List locateElementNames(final String dtdText) + { + return locateElementNames(dtdText, DEFAULT_ROOT_NAME); + } + + /** + * Finds where a DTD writes the names of its elements, so that a caller who edits the DTD text can + * rename an element everywhere the DTD names it. The names are the one of each {@code ELEMENT} + * declaration, the one of each {@code ATTLIST} declaration, and those in the content model of the root + * element, which is how a DTD lists the tables of a dataset. The name of the root element itself is not + * among them. A name in a comment, in a default value, or behind a parameter entity reference is not + * found. + * + * @param dtdText The DTD text: an external DTD file's content, or a DOCTYPE's internal subset. + * @param rootName The name that the DOCTYPE gives the root element. + * @return The names, in the order the text writes them. + */ + public static List locateElementNames(final String dtdText, final String rootName) + { + final Scanner scanner = new Scanner(new DtdLexer(dtdText), rootName); + scanner.scan(); + return scanner.elementNames(); + } + + /** + * Holds the mutable state of one read. A fresh instance is used for each call to {@link #read}. + */ + private static final class Scanner + { + private final DtdLexer lexer; + + private final String rootName; + + private final Map> elements = new LinkedHashMap<>(); + + private final Map> attributeDefaults = new LinkedHashMap<>(); + + private final List problems = new ArrayList<>(); + + private final List elementNames = new ArrayList<>(); + + private boolean contentModelDeclared; + + private boolean contentModelAny; + + private boolean contentModelEmpty; + + private List contentModelNames = List.of(); + + private Scanner(final DtdLexer lexer, final String rootName) + { + this.lexer = lexer; + this.rootName = rootName; + } + + private void scan() + { + while (true) + { + lexer.skipWhitespace(); + if (lexer.atEnd()) + { + break; + } + if (lexer.atText(" elementNames() + { + return List.copyOf(elementNames); + } + + private void scanMarkupDeclaration() + { + if (lexer.atText("")) + { + if (matchesAt(pos, "--")) + { + throw problems.blockingError(ProblemCode.NOT_WELL_FORMED, + Messages.Parser_doubleHyphenInComment, pos); + } + skipXmlCharacter(); + } + if (pos >= length) + { + throw problems.blockingError(ProblemCode.NOT_WELL_FORMED, Messages.Parser_unclosedComment, + start); + } + pos += 3; + } + + private void skipProcessingInstructionTarget(final int instructionStart) + { + if (!atNameStart()) + { + throw problems.blockingError(ProblemCode.NOT_WELL_FORMED, + Messages.Parser_expectedProcessingInstructionTarget, pos); + } + final int targetStart = pos; + final String target = scanName(); + if (isReservedTarget(target, instructionStart)) + { + throw problems.blockingError(ProblemCode.NOT_WELL_FORMED, + NLS.bind(Messages.Parser_reservedProcessingInstructionTarget, target), targetStart); + } + if (!atTargetEnd()) + { + throw problems.blockingError(ProblemCode.NOT_WELL_FORMED, + Messages.Parser_expectedSpaceAfterProcessingInstructionTarget, pos); + } + } + + /** + * Tells whether a processing instruction's target is reserved: every spelling of xml is, except the + * lowercase one of the XML declaration at the start of the text. + */ + private boolean isReservedTarget(final String target, final int instructionStart) + { + final boolean isDeclaration = "xml".equals(target) && startsText(instructionStart); + return "xml".equalsIgnoreCase(target) && !isDeclaration; + } + + /** + * Tells whether an offset is where the text starts, which a byte order mark does not change. + */ + private boolean startsText(final int offset) + { + return offset == 0 || (offset == 1 && text.charAt(0) == BYTE_ORDER_MARK); + } + + private boolean atTargetEnd() + { + return pos >= length || XmlNames.isWhitespace(text.charAt(pos)) || matchesAt(pos, "?>"); + } + + void skipCData() + { + final int start = pos; + pos += "")) + { + skipXmlCharacter(); + } + if (pos >= length) + { + throw problems.blockingError(ProblemCode.NOT_WELL_FORMED, + Messages.Parser_unclosedCdata, start); + } + pos += 3; + } + + /** + * Moves over the character at the position, which must not be the end of the text. A surrogate pair is + * one character. A character that XML 1.0 does not allow records a blocking problem at its offset. + */ + void skipXmlCharacter() + { + final int codePoint = Character.codePointAt(text, pos); + if (!AttributeValueCodec.isXmlChar(codePoint)) + { + throw problems.blockingError(ProblemCode.NOT_WELL_FORMED, + AttributeValueCodec.notXmlCharacterMessage(codePoint), pos); + } + pos += Character.charCount(codePoint); + } + + String scanName() + { + final int start = pos; + if (pos >= length || !XmlNames.isNameStartChar(Character.codePointAt(text, pos))) + { + throw problems.blockingError(ProblemCode.NOT_WELL_FORMED, Messages.Parser_expectedName, pos); + } + pos += Character.charCount(Character.codePointAt(text, pos)); + while (pos < length) + { + final int codePoint = Character.codePointAt(text, pos); + if (!XmlNames.isNameChar(codePoint)) + { + break; + } + pos += Character.charCount(codePoint); + } + final String name = text.subSequence(start, pos).toString(); + final String sharedName = names.putIfAbsent(name, name); + return sharedName != null ? sharedName : name; + } + + String scanQuotedLiteral() + { + final char quote = scanOpeningQuote(Messages.Parser_expectedQuotedLiteral); + final int start = pos; + skipPastClosingQuote(quote, Messages.Parser_unclosedLiteral); + return text.subSequence(start, pos - 1).toString(); + } + + /** + * Moves over the quoted literal that starts at the position, which must be at its opening quote. A + * literal that has no closing quote records the blocking problem that the message describes, at its + * opening quote. + */ + void skipQuotedLiteral(final String unclosedMessage) + { + final char quote = text.charAt(pos); + pos++; + skipPastClosingQuote(quote, unclosedMessage); + } + + /** + * Moves over the rest of a quoted literal, past its closing quote, from the position just after its + * opening quote. + */ + private void skipPastClosingQuote(final char quote, final String unclosedMessage) + { + final int openingQuoteOffset = pos - 1; + while (pos < length && text.charAt(pos) != quote) + { + pos++; + } + if (pos >= length) + { + throw problems.blockingError(ProblemCode.NOT_WELL_FORMED, unclosedMessage, openingQuoteOffset); + } + pos++; // consume the closing quote + } + + /** + * Consumes the expected character, or records the blocking problem that the message describes. + */ + void expectCharacter(final char expected, final String message) + { + if (pos >= length || text.charAt(pos) != expected) + { + throw problems.blockingError(ProblemCode.NOT_WELL_FORMED, message, pos); + } + pos++; + } + + /** + * Consumes the quote that opens a quoted value, or records the blocking problem that the message + * describes. + * + * @return The quote, which also closes the value. + */ + char scanOpeningQuote(final String message) + { + final boolean atQuote = pos < length && (text.charAt(pos) == '"' || text.charAt(pos) == '\''); + if (!atQuote) + { + throw problems.blockingError(ProblemCode.NOT_WELL_FORMED, message, pos); + } + final char quote = text.charAt(pos); + pos++; + return quote; + } + + /** + * Advances to the quote that closes an attribute value, which may not contain a {@code <}. + * + * @return The offset of the closing quote. + */ + int scanToClosingQuote(final char quote, final int valueOffset) + { + while (pos < length && text.charAt(pos) != quote) + { + if (text.charAt(pos) == '<') + { + throw problems.blockingError(ProblemCode.NOT_WELL_FORMED, Messages.Parser_lessThanInValue, + pos); + } + pos++; + } + if (pos >= length) + { + throw problems.blockingError(ProblemCode.NOT_WELL_FORMED, Messages.Parser_unclosedValue, + valueOffset - 1); + } + return pos; + } + + private boolean matchesAt(final int position, final String literal) + { + if (position + literal.length() > length) + { + return false; + } + for (int i = 0; i < literal.length(); i++) + { + if (text.charAt(position + i) != literal.charAt(i)) + { + return false; + } + } + return true; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/XmlNames.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/XmlNames.java new file mode 100644 index 00000000..e545a60a --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/XmlNames.java @@ -0,0 +1,227 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +/** + * The XML 1.0 {@code Name} production as the XML parser of the Java runtime applies it, which dbUnit loads a + * dataset with: the production of the fourth edition, whose names are made of the letters, digits, combining + * characters, and extenders of Appendix B of that edition. It is narrower than the production of the fifth + * edition, and the parser refuses a name that the fifth edition allows, such as one with a character beyond + * U+FFFF, so a name that this class rejects is one that dbUnit cannot load. It also tells what XML white + * space is, which the parser decides too. + * + * @since 1.0.0 + */ +public final class XmlNames +{ + /** + * The code points that may start a name, as pairs of the first and the last code point of a range, in + * ascending order: 206 ranges. + */ + private static final int[] NAME_START_RANGES = { + 0x3A, 0x3A, 0x41, 0x5A, 0x5F, 0x5F, 0x61, 0x7A, 0xC0, 0xD6, 0xD8, 0xF6, 0xF8, 0x131, 0x134, + 0x13E, 0x141, 0x148, 0x14A, 0x17E, 0x180, 0x1C3, 0x1CD, 0x1F0, 0x1F4, 0x1F5, 0x1FA, 0x217, + 0x250, 0x2A8, 0x2BB, 0x2C1, 0x386, 0x386, 0x388, 0x38A, 0x38C, 0x38C, 0x38E, 0x3A1, 0x3A3, + 0x3CE, 0x3D0, 0x3D6, 0x3DA, 0x3DA, 0x3DC, 0x3DC, 0x3DE, 0x3DE, 0x3E0, 0x3E0, 0x3E2, 0x3F3, + 0x401, 0x40C, 0x40E, 0x44F, 0x451, 0x45C, 0x45E, 0x481, 0x490, 0x4C4, 0x4C7, 0x4C8, 0x4CB, + 0x4CC, 0x4D0, 0x4EB, 0x4EE, 0x4F5, 0x4F8, 0x4F9, 0x531, 0x556, 0x559, 0x559, 0x561, 0x586, + 0x5D0, 0x5EA, 0x5F0, 0x5F2, 0x621, 0x63A, 0x641, 0x64A, 0x671, 0x6B7, 0x6BA, 0x6BE, 0x6C0, + 0x6CE, 0x6D0, 0x6D3, 0x6D5, 0x6D5, 0x6E5, 0x6E6, 0x905, 0x939, 0x93D, 0x93D, 0x958, 0x961, + 0x985, 0x98C, 0x98F, 0x990, 0x993, 0x9A8, 0x9AA, 0x9B0, 0x9B2, 0x9B2, 0x9B6, 0x9B9, 0x9DC, + 0x9DD, 0x9DF, 0x9E1, 0x9F0, 0x9F1, 0xA05, 0xA0A, 0xA0F, 0xA10, 0xA13, 0xA28, 0xA2A, 0xA30, + 0xA32, 0xA33, 0xA35, 0xA36, 0xA38, 0xA39, 0xA59, 0xA5C, 0xA5E, 0xA5E, 0xA72, 0xA74, 0xA85, + 0xA8B, 0xA8D, 0xA8D, 0xA8F, 0xA91, 0xA93, 0xAA8, 0xAAA, 0xAB0, 0xAB2, 0xAB3, 0xAB5, 0xAB9, + 0xABD, 0xABD, 0xAE0, 0xAE0, 0xB05, 0xB0C, 0xB0F, 0xB10, 0xB13, 0xB28, 0xB2A, 0xB30, 0xB32, + 0xB33, 0xB36, 0xB39, 0xB3D, 0xB3D, 0xB5C, 0xB5D, 0xB5F, 0xB61, 0xB85, 0xB8A, 0xB8E, 0xB90, + 0xB92, 0xB95, 0xB99, 0xB9A, 0xB9C, 0xB9C, 0xB9E, 0xB9F, 0xBA3, 0xBA4, 0xBA8, 0xBAA, 0xBAE, + 0xBB5, 0xBB7, 0xBB9, 0xC05, 0xC0C, 0xC0E, 0xC10, 0xC12, 0xC28, 0xC2A, 0xC33, 0xC35, 0xC39, + 0xC60, 0xC61, 0xC85, 0xC8C, 0xC8E, 0xC90, 0xC92, 0xCA8, 0xCAA, 0xCB3, 0xCB5, 0xCB9, 0xCDE, + 0xCDE, 0xCE0, 0xCE1, 0xD05, 0xD0C, 0xD0E, 0xD10, 0xD12, 0xD28, 0xD2A, 0xD39, 0xD60, 0xD61, + 0xE01, 0xE2E, 0xE30, 0xE30, 0xE32, 0xE33, 0xE40, 0xE45, 0xE81, 0xE82, 0xE84, 0xE84, 0xE87, + 0xE88, 0xE8A, 0xE8A, 0xE8D, 0xE8D, 0xE94, 0xE97, 0xE99, 0xE9F, 0xEA1, 0xEA3, 0xEA5, 0xEA5, + 0xEA7, 0xEA7, 0xEAA, 0xEAB, 0xEAD, 0xEAE, 0xEB0, 0xEB0, 0xEB2, 0xEB3, 0xEBD, 0xEBD, 0xEC0, + 0xEC4, 0xF40, 0xF47, 0xF49, 0xF69, 0x10A0, 0x10C5, 0x10D0, 0x10F6, 0x1100, 0x1100, 0x1102, + 0x1103, 0x1105, 0x1107, 0x1109, 0x1109, 0x110B, 0x110C, 0x110E, 0x1112, 0x113C, 0x113C, 0x113E, + 0x113E, 0x1140, 0x1140, 0x114C, 0x114C, 0x114E, 0x114E, 0x1150, 0x1150, 0x1154, 0x1155, 0x1159, + 0x1159, 0x115F, 0x1161, 0x1163, 0x1163, 0x1165, 0x1165, 0x1167, 0x1167, 0x1169, 0x1169, 0x116D, + 0x116E, 0x1172, 0x1173, 0x1175, 0x1175, 0x119E, 0x119E, 0x11A8, 0x11A8, 0x11AB, 0x11AB, 0x11AE, + 0x11AF, 0x11B7, 0x11B8, 0x11BA, 0x11BA, 0x11BC, 0x11C2, 0x11EB, 0x11EB, 0x11F0, 0x11F0, 0x11F9, + 0x11F9, 0x1E00, 0x1E9B, 0x1EA0, 0x1EF9, 0x1F00, 0x1F15, 0x1F18, 0x1F1D, 0x1F20, 0x1F45, 0x1F48, + 0x1F4D, 0x1F50, 0x1F57, 0x1F59, 0x1F59, 0x1F5B, 0x1F5B, 0x1F5D, 0x1F5D, 0x1F5F, 0x1F7D, 0x1F80, + 0x1FB4, 0x1FB6, 0x1FBC, 0x1FBE, 0x1FBE, 0x1FC2, 0x1FC4, 0x1FC6, 0x1FCC, 0x1FD0, 0x1FD3, 0x1FD6, + 0x1FDB, 0x1FE0, 0x1FEC, 0x1FF2, 0x1FF4, 0x1FF6, 0x1FFC, 0x2126, 0x2126, 0x212A, 0x212B, 0x212E, + 0x212E, 0x2180, 0x2182, 0x3007, 0x3007, 0x3021, 0x3029, 0x3041, 0x3094, 0x30A1, 0x30FA, 0x3105, + 0x312C, 0x4E00, 0x9FA5, 0xAC00, 0xD7A3 + }; + + /** + * The code points that may be part of a name, in the same form: 287 ranges. + */ + private static final int[] NAME_RANGES = { + 0x2D, 0x2E, 0x30, 0x3A, 0x41, 0x5A, 0x5F, 0x5F, 0x61, 0x7A, 0xB7, 0xB7, 0xC0, 0xD6, 0xD8, 0xF6, + 0xF8, 0x131, 0x134, 0x13E, 0x141, 0x148, 0x14A, 0x17E, 0x180, 0x1C3, 0x1CD, 0x1F0, 0x1F4, 0x1F5, + 0x1FA, 0x217, 0x250, 0x2A8, 0x2BB, 0x2C1, 0x2D0, 0x2D1, 0x300, 0x345, 0x360, 0x361, 0x386, + 0x38A, 0x38C, 0x38C, 0x38E, 0x3A1, 0x3A3, 0x3CE, 0x3D0, 0x3D6, 0x3DA, 0x3DA, 0x3DC, 0x3DC, + 0x3DE, 0x3DE, 0x3E0, 0x3E0, 0x3E2, 0x3F3, 0x401, 0x40C, 0x40E, 0x44F, 0x451, 0x45C, 0x45E, + 0x481, 0x483, 0x486, 0x490, 0x4C4, 0x4C7, 0x4C8, 0x4CB, 0x4CC, 0x4D0, 0x4EB, 0x4EE, 0x4F5, + 0x4F8, 0x4F9, 0x531, 0x556, 0x559, 0x559, 0x561, 0x586, 0x591, 0x5A1, 0x5A3, 0x5B9, 0x5BB, + 0x5BD, 0x5BF, 0x5BF, 0x5C1, 0x5C2, 0x5C4, 0x5C4, 0x5D0, 0x5EA, 0x5F0, 0x5F2, 0x621, 0x63A, + 0x640, 0x652, 0x660, 0x669, 0x670, 0x6B7, 0x6BA, 0x6BE, 0x6C0, 0x6CE, 0x6D0, 0x6D3, 0x6D5, + 0x6E8, 0x6EA, 0x6ED, 0x6F0, 0x6F9, 0x901, 0x903, 0x905, 0x939, 0x93C, 0x94D, 0x951, 0x954, + 0x958, 0x963, 0x966, 0x96F, 0x981, 0x983, 0x985, 0x98C, 0x98F, 0x990, 0x993, 0x9A8, 0x9AA, + 0x9B0, 0x9B2, 0x9B2, 0x9B6, 0x9B9, 0x9BC, 0x9BC, 0x9BE, 0x9C4, 0x9C7, 0x9C8, 0x9CB, 0x9CD, + 0x9D7, 0x9D7, 0x9DC, 0x9DD, 0x9DF, 0x9E3, 0x9E6, 0x9F1, 0xA02, 0xA02, 0xA05, 0xA0A, 0xA0F, + 0xA10, 0xA13, 0xA28, 0xA2A, 0xA30, 0xA32, 0xA33, 0xA35, 0xA36, 0xA38, 0xA39, 0xA3C, 0xA3C, + 0xA3E, 0xA42, 0xA47, 0xA48, 0xA4B, 0xA4D, 0xA59, 0xA5C, 0xA5E, 0xA5E, 0xA66, 0xA74, 0xA81, + 0xA83, 0xA85, 0xA8B, 0xA8D, 0xA8D, 0xA8F, 0xA91, 0xA93, 0xAA8, 0xAAA, 0xAB0, 0xAB2, 0xAB3, + 0xAB5, 0xAB9, 0xABC, 0xAC5, 0xAC7, 0xAC9, 0xACB, 0xACD, 0xAE0, 0xAE0, 0xAE6, 0xAEF, 0xB01, + 0xB03, 0xB05, 0xB0C, 0xB0F, 0xB10, 0xB13, 0xB28, 0xB2A, 0xB30, 0xB32, 0xB33, 0xB36, 0xB39, + 0xB3C, 0xB43, 0xB47, 0xB48, 0xB4B, 0xB4D, 0xB56, 0xB57, 0xB5C, 0xB5D, 0xB5F, 0xB61, 0xB66, + 0xB6F, 0xB82, 0xB83, 0xB85, 0xB8A, 0xB8E, 0xB90, 0xB92, 0xB95, 0xB99, 0xB9A, 0xB9C, 0xB9C, + 0xB9E, 0xB9F, 0xBA3, 0xBA4, 0xBA8, 0xBAA, 0xBAE, 0xBB5, 0xBB7, 0xBB9, 0xBBE, 0xBC2, 0xBC6, + 0xBC8, 0xBCA, 0xBCD, 0xBD7, 0xBD7, 0xBE7, 0xBEF, 0xC01, 0xC03, 0xC05, 0xC0C, 0xC0E, 0xC10, + 0xC12, 0xC28, 0xC2A, 0xC33, 0xC35, 0xC39, 0xC3E, 0xC44, 0xC46, 0xC48, 0xC4A, 0xC4D, 0xC55, + 0xC56, 0xC60, 0xC61, 0xC66, 0xC6F, 0xC82, 0xC83, 0xC85, 0xC8C, 0xC8E, 0xC90, 0xC92, 0xCA8, + 0xCAA, 0xCB3, 0xCB5, 0xCB9, 0xCBE, 0xCC4, 0xCC6, 0xCC8, 0xCCA, 0xCCD, 0xCD5, 0xCD6, 0xCDE, + 0xCDE, 0xCE0, 0xCE1, 0xCE6, 0xCEF, 0xD02, 0xD03, 0xD05, 0xD0C, 0xD0E, 0xD10, 0xD12, 0xD28, + 0xD2A, 0xD39, 0xD3E, 0xD43, 0xD46, 0xD48, 0xD4A, 0xD4D, 0xD57, 0xD57, 0xD60, 0xD61, 0xD66, + 0xD6F, 0xE01, 0xE2E, 0xE30, 0xE3A, 0xE40, 0xE4E, 0xE50, 0xE59, 0xE81, 0xE82, 0xE84, 0xE84, + 0xE87, 0xE88, 0xE8A, 0xE8A, 0xE8D, 0xE8D, 0xE94, 0xE97, 0xE99, 0xE9F, 0xEA1, 0xEA3, 0xEA5, + 0xEA5, 0xEA7, 0xEA7, 0xEAA, 0xEAB, 0xEAD, 0xEAE, 0xEB0, 0xEB9, 0xEBB, 0xEBD, 0xEC0, 0xEC4, + 0xEC6, 0xEC6, 0xEC8, 0xECD, 0xED0, 0xED9, 0xF18, 0xF19, 0xF20, 0xF29, 0xF35, 0xF35, 0xF37, + 0xF37, 0xF39, 0xF39, 0xF3E, 0xF47, 0xF49, 0xF69, 0xF71, 0xF84, 0xF86, 0xF8B, 0xF90, 0xF95, + 0xF97, 0xF97, 0xF99, 0xFAD, 0xFB1, 0xFB7, 0xFB9, 0xFB9, 0x10A0, 0x10C5, 0x10D0, 0x10F6, 0x1100, + 0x1100, 0x1102, 0x1103, 0x1105, 0x1107, 0x1109, 0x1109, 0x110B, 0x110C, 0x110E, 0x1112, 0x113C, + 0x113C, 0x113E, 0x113E, 0x1140, 0x1140, 0x114C, 0x114C, 0x114E, 0x114E, 0x1150, 0x1150, 0x1154, + 0x1155, 0x1159, 0x1159, 0x115F, 0x1161, 0x1163, 0x1163, 0x1165, 0x1165, 0x1167, 0x1167, 0x1169, + 0x1169, 0x116D, 0x116E, 0x1172, 0x1173, 0x1175, 0x1175, 0x119E, 0x119E, 0x11A8, 0x11A8, 0x11AB, + 0x11AB, 0x11AE, 0x11AF, 0x11B7, 0x11B8, 0x11BA, 0x11BA, 0x11BC, 0x11C2, 0x11EB, 0x11EB, 0x11F0, + 0x11F0, 0x11F9, 0x11F9, 0x1E00, 0x1E9B, 0x1EA0, 0x1EF9, 0x1F00, 0x1F15, 0x1F18, 0x1F1D, 0x1F20, + 0x1F45, 0x1F48, 0x1F4D, 0x1F50, 0x1F57, 0x1F59, 0x1F59, 0x1F5B, 0x1F5B, 0x1F5D, 0x1F5D, 0x1F5F, + 0x1F7D, 0x1F80, 0x1FB4, 0x1FB6, 0x1FBC, 0x1FBE, 0x1FBE, 0x1FC2, 0x1FC4, 0x1FC6, 0x1FCC, 0x1FD0, + 0x1FD3, 0x1FD6, 0x1FDB, 0x1FE0, 0x1FEC, 0x1FF2, 0x1FF4, 0x1FF6, 0x1FFC, 0x20D0, 0x20DC, 0x20E1, + 0x20E1, 0x2126, 0x2126, 0x212A, 0x212B, 0x212E, 0x212E, 0x2180, 0x2182, 0x3005, 0x3005, 0x3007, + 0x3007, 0x3021, 0x302F, 0x3031, 0x3035, 0x3041, 0x3094, 0x3099, 0x309A, 0x309D, 0x309E, 0x30A1, + 0x30FA, 0x30FC, 0x30FE, 0x3105, 0x312C, 0x4E00, 0x9FA5, 0xAC00, 0xD7A3 + }; + + private XmlNames() + { + } + + /** + * Returns whether a code point may start an XML 1.0 name. + * + * @param codePoint The Unicode code point to test. + * @return True when the XML parser of the Java runtime accepts the code point at the start of a name. + */ + public static boolean isNameStartChar(final int codePoint) + { + return isInRanges(NAME_START_RANGES, codePoint); + } + + /** + * Returns whether a code point may continue an XML 1.0 name after its first character. + * + * @param codePoint The Unicode code point to test. + * @return True when the XML parser of the Java runtime accepts the code point inside a name. + */ + public static boolean isNameChar(final int codePoint) + { + return isInRanges(NAME_RANGES, codePoint); + } + + /** + * Returns whether a character is XML white space, the {@code S} production of XML 1.0: a space, a tab, a + * carriage return, or a line feed. It is narrower than {@link Character#isWhitespace(char)}, which also + * accepts, for example, the form feed and the ideographic space U+3000, and what separates two things + * for the XML parser of the Java runtime, which dbUnit loads a dataset with, is only these four. + * + * @param character The character to test. + * @return True when the character is a space, a tab, a carriage return, or a line feed. + */ + public static boolean isWhitespace(final char character) + { + return character == ' ' || character == '\t' || character == '\n' || character == '\r'; + } + + /** + * Returns whether a string is a valid XML 1.0 name. + * + * @param name The string to test. + * @return True when the string is non-empty, its first code point is a {@code NameStartChar}, and + * every later code point is a {@code NameChar}. + */ + public static boolean isValidName(final String name) + { + if (name.isEmpty()) + { + return false; + } + final int firstCodePoint = name.codePointAt(0); + if (!isNameStartChar(firstCodePoint)) + { + return false; + } + int index = Character.charCount(firstCodePoint); + while (index < name.length()) + { + final int codePoint = name.codePointAt(index); + if (!isNameChar(codePoint)) + { + return false; + } + index += Character.charCount(codePoint); + } + return true; + } + + /** + * Searches a table of ranges for a code point. + * + * @param ranges The first and the last code point of each range, one range after the other, in + * ascending order. + * @param codePoint The code point to look for. + * @return True when one of the ranges holds the code point. + */ + private static boolean isInRanges(final int[] ranges, final int codePoint) + { + int low = 0; + int high = ranges.length / 2 - 1; + while (low <= high) + { + final int middle = (low + high) >>> 1; + if (codePoint < ranges[2 * middle]) + { + high = middle - 1; + } + else if (codePoint > ranges[2 * middle + 1]) + { + low = middle + 1; + } + else + { + return true; + } + } + return false; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/messages.properties b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/messages.properties new file mode 100644 index 00000000..6018e1a4 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/messages.properties @@ -0,0 +1,86 @@ +Validator_columnNotInFirstRow=Column "{0}" of table "{1}" is missing from the first element, so dbUnit ignores its value in this row. +Validator_firstElementWithoutAttributes=The first element of table "{0}" has no attributes, so dbUnit ignores every column of its later rows. +Validator_tableNameCaseVariants=Table "{0}" is also spelled "{1}". +Validator_columnNameCaseVariants=Column "{0}" of table "{1}" is also spelled "{2}". +Validator_duplicateColumnInRow=Attribute "{0}" repeats an earlier attribute that differs only in case; its value wins. +Validator_tableNotDeclaredInDtd=Table "{0}" is not declared in the DTD, so dbUnit fails to load the dataset. +Validator_columnNotDeclaredInDtdWarning=Column "{0}" of table "{1}" is not declared in the DTD, so dbUnit ignores its values. +Validator_columnNotDeclaredInDtdError=Column "{0}" of table "{1}" is not declared in the DTD, so dbUnit fails to load the dataset with column sensing enabled. +Validator_dtdTableWithoutDeclaration=The DTD content model names "{0}", which the DTD does not declare, so dbUnit fails to load the dataset. +Validator_dtdEmptyContentModel=The DTD declares the dataset element as EMPTY, so dbUnit fails to load the dataset: it cannot read table names from that content model. +Validator_dtdTableNameCaseVariants=The DTD lists table "{0}" and table "{1}", which dbUnit takes for one table, so it fails to load the dataset. +Validator_dtdNotLoaded=The DOCTYPE's external DTD could not be read. +Validator_redundantEmptyElement=This empty element is redundant; table "{0}" already has rows. +Edit_sourceHasErrors=Cannot edit because the source has errors that block editing. +Edit_noSuchTable=There is no table ''{0}''. +Edit_noSuchRow=Row {0} does not exist in table ''{1}''. +Edit_noSuchColumn=Column ''{0}'' does not exist in table ''{1}''. +Edit_rowWouldBeEmpty=This change would leave row {0} of table ''{1}'' with no values. Use Delete Rows to remove it instead. +Edit_firstRowWouldLoseColumn=This change would make dbUnit ignore column ''{0}'' of table ''{1}'' in the other rows, because dbUnit takes a table''s columns from its first row, which would have no value for it. Give the first row a value, even an empty string, or declare the columns in a DTD, or select the column sensing preference. +Edit_cellHasCaseVariantAttributes=Cannot change column ''{0}'' because row {1} of table ''{2}'' has two attributes for it that differ only in letter case; remove one of them on the Source page first. +Edit_tableHasNoColumns=Table ''{0}'' has no columns to insert a row into. +Edit_rowOutOfRange=Row {0} is out of range for table ''{1}''. +Edit_wrongValueCount=Each new row must have exactly {0} values for table ''{1}''. +Edit_newRowWouldBeEmpty=A new row for table ''{0}'' must have at least one value. +Edit_rowBlockOutOfRange=The row block is out of range for table ''{0}''. +Edit_moveByOnePosition=A row block can only move up or down by one position at a time. +Edit_moveBeforeFirstRow=Cannot move past the first row of table ''{0}''. +Edit_moveAfterLastRow=Cannot move past the last row of table ''{0}''. +Edit_invalidColumnName=''{0}'' is not a valid column name. +Edit_columnExists=Table ''{0}'' already has a column named ''{1}''. +Edit_renameColumnWithCaseVariants=Cannot rename column ''{0}'' in table ''{1}'' because a row has two attributes for it that differ only in letter case; remove one of them on the Source page first. +Edit_deleteColumnWouldEmptyRow=Cannot delete column ''{0}'' from table ''{1}'' because it would leave a row with no values. +Edit_renameColumnIsDeclaredOnly=Cannot rename column ''{0}'' in table ''{1}'' because the DTD declares it but no row has a value for it; change the DTD instead. +Edit_invalidTableName=''{0}'' is not a valid table name. +Edit_reservedTableName=''{0}'' is reserved for the root element. +Edit_tableExists=A table named ''{0}'' already exists. +Edit_renameTableIsDeclaredOnly=Cannot rename table ''{0}'' because it exists only in the DTD; change the DTD instead. +Edit_deleteTableIsDeclaredOnly=Cannot delete table ''{0}'' because it exists only in the DTD; change the DTD instead. +Edit_documentNotBlank=Cannot create an empty dataset because the document is not blank. +Codec_notXmlCharacter=The character U+{0} is not allowed in an XML 1.0 document. +Codec_invalidAmpersand='&' must start a valid entity or character reference. +Codec_unsupportedEntity=Entity reference ''&{0};'' is not supported; only the predefined entities and character references are available without a DTD. +Codec_referenceWithoutDigits=A character reference must have at least one digit. +Codec_invalidReference=''{0}'' is not a valid character reference. +Codec_referenceTooLarge=A character reference must not exceed U+10FFFF. +Codec_referenceNotXmlCharacter=Character reference U+{0} is not allowed in XML 1.0. +Parser_position={0} (line {1}, column {2}) +Parser_noRootElement=The document has no root element; a dataset starts with . +Parser_unexpectedBeforeRoot=Unexpected character before the root element. +Parser_rootNotDataset=The root element must be named , but found <{0}>. +Parser_unexpectedAfterRoot=Unexpected content after the root element. +Parser_endedInStartTag=The document ended inside a start tag. +Parser_expectedAfterElementName=Expected whitespace, '/>', or '>' after the element name. +Parser_expectedWhitespaceBetweenAttributes=Expected whitespace between attributes. +Parser_duplicateAttribute=Duplicate attribute ''{0}''. +Parser_expectedEquals=Expected '=' after the attribute name. +Parser_expectedQuotedValue=Expected a quoted attribute value. +Parser_lessThanInValue='<' is not allowed in an attribute value. +Parser_unclosedValue=The attribute value has no closing quote. +Parser_endedBeforeEndTag=The document ended before . +Parser_nestedElement=An element cannot be nested inside a row element. +Parser_unclosedEndTag=Expected ''>'' to close . +Parser_mismatchedEndTag=Expected , but found . +Parser_textIgnored=Text content is ignored. +Parser_expectedDoctypeName=Expected whitespace and the root name after ' to close the DOCTYPE declaration. +Parser_expectedWhitespaceAfterIdKeyword=Expected whitespace between SYSTEM or PUBLIC and the quoted literal. +Parser_expectedWhitespaceBetweenIds=Expected whitespace between the public literal and the system literal. +Parser_expectedQuotedLiteral=Expected a quoted literal. +Parser_unclosedLiteral=The literal has no closing quote. +Parser_unclosedSubsetLiteral=A quoted literal in the internal subset has no closing quote. +Parser_unclosedSubset=The internal subset has no closing ']'. +Parser_expectedName=Expected a name. +Parser_unclosedProcessingInstruction=The processing instruction has no closing '?>'. +Parser_unclosedComment=The comment has no closing '-->'. +Parser_doubleHyphenInComment=A comment cannot contain '--', so it cannot end with '--->' either. +Parser_expectedProcessingInstructionTarget=A processing instruction must start with a name, right after ''. +Parser_unclosedCdata=The CDATA section has no closing ']]>'. +Parser_cdataEndInText=']]>' is not allowed in text; write ']]>' instead. +Parser_entityNotDeclared=Entity reference ''&{0};'' is not declared; without a DTD that declares entities, only the predefined entities and character references are available. +Dtd_parameterEntityDeclarationsIgnored=Parameter entity declarations are ignored. +Dtd_conditionalSectionsIgnored=Conditional sections are ignored. +Dtd_parameterEntityReferencesIgnored=Parameter entity references are ignored. +Dtd_attributeDefaultNotShown=The default value of attribute "{0}" of element "{1}" is ignored: {2} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/CellAddress.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/CellAddress.java new file mode 100644 index 00000000..70ef8ed2 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/CellAddress.java @@ -0,0 +1,33 @@ +/* + * + * 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.eclipse.dataset.core.model; + +/** + * The address of one cell of a dataset, or of a whole row when columnIndex is -1. + * + * @param tableKey The key of the table. + * @param rowIndex The index of the row within the table. + * @param columnIndex The index of the column within the table, or -1 for a whole row. + * @since 1.0.0 + */ +public record CellAddress(String tableKey, int rowIndex, int columnIndex) +{ +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetColumn.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetColumn.java new file mode 100644 index 00000000..0a028b38 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetColumn.java @@ -0,0 +1,100 @@ +/* + * + * 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.eclipse.dataset.core.model; + +import java.util.Locale; + +/** + * One column of a dataset table. + * + * @param name The name of the column. + * @param declared True when the column is declared for this table in the DTD. + * @param hasValues True when at least one row has a non-null value for this column. + * @param pending True when the column was added in this editor session and is not yet saved in the + * document. + * @param defaultValue The value dbUnit loads for a row that has no attribute for this column: the default + * or {@code #FIXED} value that the DTD declares for it, or null when the DTD declares + * none and a row without the attribute loads NULL. + * @since 1.0.0 + */ +public record DatasetColumn(String name, boolean declared, boolean hasValues, boolean pending, + String defaultValue) +{ + /** + * Creates a column that has no default value. + * + * @param name The name of the column. + * @param declared True when the column is declared for this table in the DTD. + * @param hasValues True when at least one row has a non-null value for this column. + * @param pending True when the column was added in this editor session and is not yet saved in the + * document. + */ + public DatasetColumn(final String name, final boolean declared, final boolean hasValues, + final boolean pending) + { + this(name, declared, hasValues, pending, null); + } + + /** + * Returns the key that identifies a column among the columns of a table: its name in upper case, with + * Locale.ENGLISH so that the key does not depend on the language of the user. Two names with one key + * name one column, as dbUnit matches the names of columns without regard to letter case. + * + * @param columnName The name of a column, in any letter case. + * @return The key of the column. + */ + public static String keyOf(final String columnName) + { + return columnName.toUpperCase(Locale.ENGLISH); + } + + /** + * Returns the key of this column. + * + * @return The key of the name of this column, as {@link #keyOf} gives it. + */ + public String key() + { + return keyOf(name); + } + + /** + * Returns whether a row without an attribute for this column loads a value other than NULL. + * + * @return True when the DTD declares a default value for the column. + */ + public boolean hasDefaultValue() + { + return defaultValue != null; + } + + /** + * Returns the value dbUnit loads for a cell of this column. + * + * @param value The cell's value, which is null when the row has no attribute for the column. + * @return The value when it is not null, otherwise the column's default value, which is null when the + * column has none. + */ + public String effectiveValue(final String value) + { + return value == null ? defaultValue : value; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetModel.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetModel.java new file mode 100644 index 00000000..859bcbf2 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetModel.java @@ -0,0 +1,208 @@ +/* + * + * 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.eclipse.dataset.core.model; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * An immutable snapshot of a dataset: its tables and the problems found while building it. + * + * @since 1.0.0 + */ +public final class DatasetModel +{ + /** + * An empty, non-editable model, used before the first parse. + */ + public static final DatasetModel EMPTY = new DatasetModel(List.of(), List.of(), false); + + private final List tables; + + private final List problems; + + private final Map> problemsByTable; + + private final Map> worstSeverityByTableAndColumn; + + private final boolean editable; + + private final boolean blank; + + /** + * Creates the model of a source that has content. + * + * @param tables The tables of the dataset, copied defensively. + * @param problems The problems found while building the model, copied defensively. + * @param editable False when the source has errors that block editing. + */ + public DatasetModel(final List tables, final List problems, + final boolean editable) + { + this(tables, problems, editable, false); + } + + /** + * Creates a dataset model. + * + * @param tables The tables of the dataset, copied defensively. + * @param problems The problems found while building the model, copied defensively. + * @param editable False when the source has errors that block editing. + * @param blank True when the source is empty or only whitespace, so that it has no content to build a + * model from, which is no error. + */ + public DatasetModel(final List tables, final List problems, + final boolean editable, final boolean blank) + { + this.tables = List.copyOf(tables); + this.problems = List.copyOf(problems); + this.problemsByTable = groupByTable(this.problems); + this.worstSeverityByTableAndColumn = worstSeverityByTableAndColumn(this.problems); + this.editable = editable; + this.blank = blank; + } + + private static Map> groupByTable(final List problems) + { + final Map> grouped = new HashMap<>(); + for (final DatasetProblem problem : problems) + { + grouped.computeIfAbsent(problem.tableKey(), unused -> new ArrayList<>()).add(problem); + } + grouped.replaceAll((tableKey, tableProblems) -> List.copyOf(tableProblems)); + return grouped; + } + + private static Map> worstSeverityByTableAndColumn( + final List problems) + { + final Map> worst = new HashMap<>(); + for (final DatasetProblem problem : problems) + { + if (problem.tableKey() != null && problem.columnName() != null) + { + worst.computeIfAbsent(problem.tableKey(), unused -> new HashMap<>()) + .merge(problem.columnName(), problem.severity(), DatasetModel::worseOf); + } + } + return worst; + } + + private static ProblemSeverity worseOf(final ProblemSeverity first, final ProblemSeverity second) + { + return first.compareTo(second) <= 0 ? first : second; + } + + /** + * Returns the tables of the dataset. + * + * @return An unmodifiable list of tables, in model order. + */ + public List getTables() + { + return tables; + } + + /** + * Finds a table by its key. + * + * @param tableKey The key to look up, as returned by {@link DatasetTable#getKey()}. + * @return The matching table, or an empty {@link Optional} when no table has that key. + */ + public Optional findTable(final String tableKey) + { + for (final DatasetTable table : tables) + { + final String key = table.getKey(); + if (key.equals(tableKey)) + { + return Optional.of(table); + } + } + return Optional.empty(); + } + + /** + * Returns every problem found while building the model. + * + * @return An unmodifiable list of problems. + */ + public List getProblems() + { + return problems; + } + + /** + * Returns the problems that belong to one table. The model groups them once, so asking is cheap enough + * for the grid to do on every paint. + * + * @param tableKey The table key to filter by, or null for the problems that belong to no table. + * @return An unmodifiable list of the problems whose table key equals the given key, in the order in + * which they were found. + */ + public List getProblems(final String tableKey) + { + return problemsByTable.getOrDefault(tableKey, List.of()); + } + + /** + * Returns the worst severity among the problems that belong to one column of one table. The model + * works it out once for all of its columns, so asking is cheap enough for the grid to do on every paint. + * + * @param tableKey The key of the table. + * @param columnName The name of the column, as the problems spell it, or null. + * @return The worst severity, an error being worse than a warning and a warning worse than a note, or + * an empty {@link Optional} when no problem belongs to that column. + */ + public Optional getWorstSeverity(final String tableKey, final String columnName) + { + final Map byColumn = worstSeverityByTableAndColumn.get(tableKey); + if (byColumn == null) + { + return Optional.empty(); + } + return Optional.ofNullable(byColumn.get(columnName)); + } + + /** + * Returns whether the source can be edited. + * + * @return False when the source has errors that block editing ({@link ProblemCode#blocksEditing()}). + */ + public boolean isEditable() + { + return editable; + } + + /** + * Returns whether the source is blank: empty or only XML white space. A blank source has no content to + * build a model from, which is no problem to list; it needs an empty dataset to be created in it. + * + * @return True when the source has no content. + */ + public boolean isBlank() + { + return blank; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetProblem.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetProblem.java new file mode 100644 index 00000000..4fdb00d4 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetProblem.java @@ -0,0 +1,39 @@ +/* + * + * 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.eclipse.dataset.core.model; + +/** + * A problem found while building or validating a dataset model. + * + * @param code The kind of problem. + * @param severity The severity of the problem. + * @param message The complete, user-facing message. + * @param tableKey The key of the table the problem belongs to, or null for a document-level problem. + * @param columnName The name of the column the problem belongs to, or null. + * @param rowIndex The index of the row the problem belongs to, or -1. + * @param offset The document offset the problem points to, or -1 when unknown. + * @param length The length of the text range the problem points to, or 0. + * @since 1.0.0 + */ +public record DatasetProblem(ProblemCode code, ProblemSeverity severity, String message, String tableKey, + String columnName, int rowIndex, int offset, int length) +{ +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetRow.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetRow.java new file mode 100644 index 00000000..7f170350 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetRow.java @@ -0,0 +1,77 @@ +/* + * + * 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.eclipse.dataset.core.model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * An immutable snapshot of one row of a dataset table. + * + * @since 1.0.0 + */ +public final class DatasetRow +{ + private final List values; + + /** + * Creates a dataset row. + * + * @param values The values of the row, aligned with the table columns and copied defensively; a + * null value means NULL. + */ + public DatasetRow(final List values) + { + this.values = Collections.unmodifiableList(new ArrayList<>(values)); + } + + /** + * Returns the value of one column. + * + * @param columnIndex The index of the column. + * @return The value, or null when the value is NULL. + */ + public String getValue(final int columnIndex) + { + return values.get(columnIndex); + } + + /** + * Returns the number of values in the row. + * + * @return The number of values. + */ + public int size() + { + return values.size(); + } + + /** + * Returns the values of the row. + * + * @return An unmodifiable list that may contain null. + */ + public List getValues() + { + return values; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetTable.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetTable.java new file mode 100644 index 00000000..c16d3ac6 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetTable.java @@ -0,0 +1,211 @@ +/* + * + * 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.eclipse.dataset.core.model; + +import java.util.List; +import java.util.Locale; + +/** + * An immutable snapshot of one table of a dataset. + * + * @since 1.0.0 + */ +public final class DatasetTable +{ + private final String key; + + private final String name; + + private final List columns; + + private final List rows; + + private final boolean declaredOnly; + + private final boolean declaredInExternalDtd; + + /** + * Creates a dataset table that no external DTD declares. + * + * @param key The identity of the table across refreshes: the upper-cased name (Locale.ENGLISH), or + * the exact name when table names are case-sensitive. + * @param name The spelling of the first element of the table, or of its DTD declaration. + * @param columns The columns of the table, copied defensively. + * @param rows The rows of the table, copied defensively. + * @param declaredOnly True when the table is declared in the DTD but has no element in the document. + */ + public DatasetTable(final String key, final String name, final List columns, + final List rows, final boolean declaredOnly) + { + this(key, name, columns, rows, declaredOnly, false); + } + + /** + * Creates a dataset table. + * + * @param key The identity of the table across refreshes: the upper-cased name (Locale.ENGLISH), or + * the exact name when table names are case-sensitive. + * @param name The spelling of the first element of the table, or of its DTD declaration. + * @param columns The columns of the table, copied defensively. + * @param rows The rows of the table, copied defensively. + * @param declaredOnly True when the table is declared in the DTD but has no element in the document. + * @param declaredInExternalDtd True when an external DTD declares the table. + */ + public DatasetTable(final String key, final String name, final List columns, + final List rows, final boolean declaredOnly, final boolean declaredInExternalDtd) + { + this.key = key; + this.name = name; + this.columns = List.copyOf(columns); + this.rows = List.copyOf(rows); + this.declaredOnly = declaredOnly; + this.declaredInExternalDtd = declaredInExternalDtd; + } + + /** + * Returns the key that identifies a table across refreshes. + * + * @param tableName The name of a table, as its first element spells it. + * @param caseSensitive True when table names are case-sensitive. + * @return The exact name when table names are case-sensitive, otherwise the name in upper case, with + * Locale.ENGLISH so that the key does not depend on the language of the user. + */ + public static String keyOf(final String tableName, final boolean caseSensitive) + { + return caseSensitive ? tableName : tableName.toUpperCase(Locale.ENGLISH); + } + + /** + * Returns the identity of the table across refreshes. + * + * @return The key of the table, as {@link #keyOf} gives it. + */ + public String getKey() + { + return key; + } + + /** + * Returns the display spelling of the table. + * + * @return The spelling of the first element of the table, or of its DTD declaration. + */ + public String getName() + { + return name; + } + + /** + * Returns the columns of the table. + * + * @return An unmodifiable list of columns. + */ + public List getColumns() + { + return columns; + } + + /** + * Returns the rows of the table. + * + * @return An unmodifiable list of rows. + */ + public List getRows() + { + return rows; + } + + /** + * Finds the index of a column by name. + * + * @param columnName The column name to look up. A column matches when it has the same key as this + * name, see {@link DatasetColumn#keyOf}. + * @return The index of the first matching column, or -1 when no column matches. + */ + public int getColumnIndex(final String columnName) + { + final String columnKey = DatasetColumn.keyOf(columnName); + for (int index = 0; index < columns.size(); index++) + { + final DatasetColumn column = columns.get(index); + if (column.key().equals(columnKey)) + { + return index; + } + } + return -1; + } + + /** + * Returns the value dbUnit loads for a cell. + * + * @param rowIndex The index of the cell's row. + * @param columnIndex The index of the cell's column. + * @return The cell's value, or, when the row has no attribute for the column, the column's default + * value, which is null when the column has none. + * @throws IndexOutOfBoundsException When the table has no such row or column. + */ + public String getEffectiveValue(final int rowIndex, final int columnIndex) + { + final String value = rows.get(rowIndex).getValue(columnIndex); + return columns.get(columnIndex).effectiveValue(value); + } + + /** + * Returns whether a row without any attribute would load a value for some column. + * + * @return True when at least one column has a default value, so an element without attributes is a + * row of the table, not just a marker that the table exists. + */ + public boolean hasDefaultValues() + { + for (final DatasetColumn column : columns) + { + if (column.hasDefaultValue()) + { + return true; + } + } + return false; + } + + /** + * Returns whether the table exists only because the DTD declares it. + * + * @return True when the table is declared in the DTD but has no element in the document. + */ + public boolean isDeclaredOnly() + { + return declaredOnly; + } + + /** + * Returns whether a DTD file that the dataset's DOCTYPE names declares the table. The editor does not + * change such a file, so a rename of the table leaves the declaration in it under the old name. + * + * @return True when the DTD file declares the table with an {@code ELEMENT} or {@code ATTLIST} + * declaration or lists it in the content model of the {@code dataset} element. + */ + public boolean isDeclaredInExternalDtd() + { + return declaredInExternalDtd; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/ProblemCode.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/ProblemCode.java new file mode 100644 index 00000000..9ed78bca --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/ProblemCode.java @@ -0,0 +1,143 @@ +/* + * + * 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.eclipse.dataset.core.model; + +/** + * The kind of a dataset problem. + * + * @since 1.0.0 + */ +public enum ProblemCode +{ + /** + * The XML is not well-formed; the message has the details. + */ + NOT_WELL_FORMED(true), + + /** + * The root element is missing or is not {@code dataset}. + */ + ROOT_NOT_DATASET(true), + + /** + * An element is nested inside a row element. + */ + NESTED_ELEMENT(true), + + /** + * An entity reference other than one of the five predefined entities was used. + */ + UNSUPPORTED_ENTITY(true), + + /** + * A column has values in a later row but not in the table's first element, without a DTD and + * without column sensing. + */ + COLUMN_NOT_IN_FIRST_ROW(false), + + /** + * The table's first element has no attributes, but a later row has values. + */ + FIRST_ELEMENT_WITHOUT_ATTRIBUTES(false), + + /** + * One table is spelled with more than one letter case. + */ + TABLE_NAME_CASE_VARIANTS(false), + + /** + * One column of a table is spelled with more than one letter case. + */ + COLUMN_NAME_CASE_VARIANTS(false), + + /** + * An element has two attributes whose names differ only in case; dbUnit uses the value of the last + * one. + */ + DUPLICATE_COLUMN_IN_ROW(false), + + /** + * A table that has elements is not declared in the DTD, so dbUnit fails to load the dataset. + */ + TABLE_NOT_DECLARED_IN_DTD(false), + + /** + * A row has an attribute that the DTD does not declare for the table; dbUnit ignores the value, or, + * with column sensing, fails to load the dataset. + */ + COLUMN_NOT_DECLARED_IN_DTD(false), + + /** + * The DTD's {@code dataset} content model names an element that the DTD declares with neither an + * element type nor an attribute list, so dbUnit fails to load the dataset. + */ + DTD_TABLE_WITHOUT_DECLARATION(false), + + /** + * The DTD declares the {@code dataset} element as {@code EMPTY}, which dbUnit cannot load: it reads + * the table names from the content model's text without its first and last character. + */ + DTD_EMPTY_CONTENT_MODEL(false), + + /** + * The DTD's tables include two spellings of one table, which dbUnit takes for the same table, so it + * fails to load the dataset. + */ + DTD_TABLE_NAME_CASE_VARIANTS(false), + + /** + * The DOCTYPE's external DTD could not be read. + */ + DTD_NOT_LOADED(false), + + /** + * A parameter entity or a conditional section in the DTD was ignored. + */ + UNSUPPORTED_DTD_CONSTRUCT(false), + + /** + * Text or CDATA content inside {@code dataset} or a row element was ignored. + */ + TEXT_CONTENT_IGNORED(false), + + /** + * An element without attributes was found in a table that has rows, and it is not the table's first + * element. + */ + REDUNDANT_EMPTY_ELEMENT(false); + + private final boolean blocksEditing; + + ProblemCode(final boolean blocksEditing) + { + this.blocksEditing = blocksEditing; + } + + /** + * Returns whether this kind of problem blocks editing. + * + * @return True when the source cannot be edited while a problem of this kind exists. + */ + public boolean blocksEditing() + { + return blocksEditing; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/ProblemSeverity.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/ProblemSeverity.java new file mode 100644 index 00000000..24f2ba56 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/ProblemSeverity.java @@ -0,0 +1,44 @@ +/* + * + * 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.eclipse.dataset.core.model; + +/** + * The severity of a dataset problem. + * + * @since 1.0.0 + */ +public enum ProblemSeverity +{ + /** + * The condition prevents loading or editing the dataset until it is fixed. + */ + ERROR, + + /** + * The condition is tolerated by dbUnit, but it is likely a mistake. + */ + WARNING, + + /** + * An informational note about content the editor ignores. + */ + INFO +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/tsv/TabSeparatedValues.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/tsv/TabSeparatedValues.java new file mode 100644 index 00000000..6ca7f367 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/tsv/TabSeparatedValues.java @@ -0,0 +1,255 @@ +/* + * + * 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.eclipse.dataset.core.tsv; + +import java.util.ArrayList; +import java.util.List; + +/** + * Converts between rows of cell values and the tab-separated text spreadsheets put on the clipboard. + * + * @since 1.0.0 + */ +public final class TabSeparatedValues +{ + private TabSeparatedValues() + { + } + + /** + * Formats rows of values as tab-separated text. + * + * @param rows The rows to format; a null value means NULL and writes as an unquoted empty field, while + * the empty string writes as a quoted empty field ({@code ""}) so NULL and the empty + * string stay distinguishable when parsed back. + * @param lineSeparator The line separator written after every row, including the last. + * @return The formatted text. + */ + public static String format(final List> rows, final String lineSeparator) + { + final StringBuilder text = new StringBuilder(); + for (final List row : rows) + { + for (int columnIndex = 0; columnIndex < row.size(); columnIndex++) + { + if (columnIndex > 0) + { + text.append('\t'); + } + text.append(quoteIfNeeded(row.get(columnIndex))); + } + text.append(lineSeparator); + } + return text.toString(); + } + + /** + * Parses tab-separated text into rows of values. + * + * @param text The text to parse; accepts CR LF, LF, and CR line ends. + * @return The parsed rows, each padded with NULL to the width of the widest row; an empty list when + * {@code text} is empty. An unquoted empty field parses as NULL; a quoted empty field + * ({@code ""}) parses as the empty string. + */ + public static List> parse(final String text) + { + final List> rows; + if (text.isEmpty()) + { + rows = List.of(); + } + else + { + rows = new Parser(text).parseRows(); + } + return rows; + } + + private static String resolveField(final StringBuilder field, final boolean fieldWasQuoted) + { + return field.length() > 0 || fieldWasQuoted ? field.toString() : null; + } + + private static String quoteIfNeeded(final String value) + { + if (value == null) + { + return ""; + } + if (value.isEmpty()) + { + return "\"\""; + } + if (needsQuoting(value)) + { + return '"' + value.replace("\"", "\"\"") + '"'; + } + return value; + } + + private static boolean needsQuoting(final String value) + { + return value.startsWith("\"") || value.indexOf('\t') >= 0 || value.indexOf('\n') >= 0 + || value.indexOf('\r') >= 0 || value.indexOf('"') >= 0; + } + + private static List> pad(final List> rows) + { + int width = 0; + for (final List row : rows) + { + width = Math.max(width, row.size()); + } + for (final List row : rows) + { + while (row.size() < width) + { + row.add(null); + } + } + return rows; + } + + /** + * Holds the state of one parse: the rows, the row and the field being built, and whether the text read so + * far is inside a quoted field. + */ + private static final class Parser + { + private final String text; + + private final List> rows = new ArrayList<>(); + + private final StringBuilder field = new StringBuilder(); + + private List row = new ArrayList<>(); + + private boolean quoted; + + private boolean fieldWasQuoted; + + private boolean pendingField; + + private int index; + + private Parser(final String text) + { + this.text = text; + } + + private List> parseRows() + { + while (index < text.length()) + { + final char character = text.charAt(index); + if (quoted) + { + readInQuotedField(character); + } + else + { + readOutsideQuotedField(character); + } + } + if (pendingField) + { + endRow(); + } + return pad(rows); + } + + private void readInQuotedField(final char character) + { + if (character == '"' && characterAtIs(index + 1, '"')) + { + field.append('"'); + index += 2; + } + else if (character == '"') + { + quoted = false; + index++; + } + else + { + field.append(character); + index++; + } + } + + private void readOutsideQuotedField(final char character) + { + if (character == '"' && field.length() == 0) + { + quoted = true; + fieldWasQuoted = true; + pendingField = true; + index++; + } + else if (character == '\t') + { + endField(); + pendingField = true; + index++; + } + else if (character == '\n' || character == '\r') + { + endLine(character); + } + else + { + field.append(character); + pendingField = true; + index++; + } + } + + private void endLine(final char lineEnd) + { + endRow(); + index++; + if (lineEnd == '\r' && characterAtIs(index, '\n')) + { + index++; + } + } + + private void endField() + { + row.add(resolveField(field, fieldWasQuoted)); + field.setLength(0); + fieldWasQuoted = false; + } + + private void endRow() + { + endField(); + rows.add(row); + row = new ArrayList<>(); + pendingField = false; + } + + private boolean characterAtIs(final int position, final char expected) + { + return position < text.length() && text.charAt(position) == expected; + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/MessagesTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/MessagesTest.java new file mode 100644 index 00000000..48cd4d81 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/MessagesTest.java @@ -0,0 +1,90 @@ +/* + * + * 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.eclipse.dataset.core; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Field; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.eclipse.osgi.util.NLS; +import org.junit.jupiter.api.Test; + +/** + * Tests that {@link Messages} loads every message from {@code messages.properties}, and that the messages + * follow the quoting rules of {@link NLS#bind}. + */ +class MessagesTest +{ + @Test + void testMessages_whenLoaded_haveAValueForEveryField() throws IllegalAccessException + { + final Map messages = messages(); + + assertThat(messages).as("Every message must have an entry in messages.properties.") + .allSatisfy((name, message) -> assertThat(message).as(name) + .doesNotStartWith("NLS missing message")); + } + + @Test + void testBind_ofEachMessageWithArguments_substitutesThemAllAndShowsEachApostropheOnce() + throws IllegalAccessException + { + final Map bound = new LinkedHashMap<>(); + messages().forEach((name, message) -> + { + if (message.contains("{0}")) + { + bound.put(name, NLS.bind(message, new Object[] { "a", "b", "c" })); + } + }); + + assertThat(bound).as("A bound message must replace every argument and write '' as one apostrophe.") + .isNotEmpty() + .allSatisfy((name, message) -> assertThat(message).as(name).doesNotContain("{", "''")); + } + + @Test + void testMessages_withoutArguments_containNoDoubledApostrophes() throws IllegalAccessException + { + final Map messages = messages(); + + assertThat(messages).as("A message shown without binding must write an apostrophe as one character.") + .allSatisfy((name, message) -> + { + if (!message.contains("{0}")) + { + assertThat(message).as(name).doesNotContain("''"); + } + }); + } + + private static Map messages() throws IllegalAccessException + { + final Map messages = new LinkedHashMap<>(); + for (final Field field : Messages.class.getFields()) + { + messages.put(field.getName(), (String) field.get(null)); + } + return messages; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/TestDatasets.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/TestDatasets.java new file mode 100644 index 00000000..fe89e18a --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/TestDatasets.java @@ -0,0 +1,74 @@ +/* + * + * 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.eclipse.dataset.core; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Reads test fixture files from {@code src/test/resources/datasets/}, relative to the module directory, + * which is the surefire working directory. + */ +public final class TestDatasets +{ + private static final Path DATASETS_DIRECTORY = Path.of("src", "test", "resources", "datasets"); + + private TestDatasets() + { + } + + /** + * Reads a fixture file as UTF-8 text. + * + * @param name The file name, relative to the datasets directory; a DTD fixture is named + * {@code "dtd/.dtd"}. + * @return The file's content. + */ + public static String read(final String name) + { + return read(name, StandardCharsets.UTF_8); + } + + /** + * Reads a fixture file as text in a given charset. + * + * @param name The file name, relative to the datasets directory. + * @param charset The charset the file's bytes are encoded in. + * @return The file's content. + */ + public static String read(final String name, final Charset charset) + { + final Path path = DATASETS_DIRECTORY.resolve(name); + try + { + final byte[] bytes = Files.readAllBytes(path); + return new String(bytes, charset); + } + catch (final IOException e) + { + throw new UncheckedIOException("Could not read test dataset '" + name + "'.", e); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/dtd/DtdLexerTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/dtd/DtdLexerTest.java new file mode 100644 index 00000000..08ff3a6e --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/dtd/DtdLexerTest.java @@ -0,0 +1,484 @@ +/* + * + * 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.eclipse.dataset.core.dtd; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.function.Consumer; +import java.util.function.Function; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link DtdLexer}: each operation is run on a small text, and the token it returns and the text left + * unread are asserted together. + */ +class DtdLexerTest +{ + private record Scan(String token, String remainder) + { + } + + private static String remainderAfter(final String text, final Consumer operation) + { + final DtdLexer lexer = new DtdLexer(text); + operation.accept(lexer); + final int position = lexer.position(); + return text.substring(position); + } + + private static Scan scan(final String text, final Function operation) + { + final DtdLexer lexer = new DtdLexer(text); + final String token = operation.apply(lexer); + final int position = lexer.position(); + final String remainder = text.substring(position); + return new Scan(token, remainder); + } + + @Test + void testAtEnd_whenTheTextIsEmpty_isTrue() + { + final DtdLexer lexer = new DtdLexer(""); + + assertThat(lexer.atEnd()).as("An empty text has nothing left to read.").isTrue(); + } + + @Test + void testAtEnd_whenCharactersRemain_isFalse() + { + final DtdLexer lexer = new DtdLexer("a"); + + assertThat(lexer.atEnd()).as("A character remains to be read.").isFalse(); + } + + @Test + void testAdvance_whenCalled_movesThePositionForward() + { + final DtdLexer lexer = new DtdLexer("abcdef"); + + lexer.advance(2); + + assertThat(lexer.position()).as("The position must move over the given number of characters.") + .isEqualTo(2); + assertThat(lexer.atCharacter('c')).as("The next character must be the one after those skipped.") + .isTrue(); + } + + @Test + void testAtCharacter_whenTheCharacterIsNext_isTrue() + { + final DtdLexer lexer = new DtdLexer("(a)"); + + assertThat(lexer.atCharacter('(')).as("The opening parenthesis is next.").isTrue(); + } + + @Test + void testAtCharacter_whenAnotherCharacterIsNext_isFalse() + { + final DtdLexer lexer = new DtdLexer("(a)"); + + assertThat(lexer.atCharacter('a')).as("A different character is next.").isFalse(); + } + + @Test + void testAtCharacter_whenAtTheEnd_isFalse() + { + final DtdLexer lexer = new DtdLexer(""); + + assertThat(lexer.atCharacter('a')).as("No character is next at the end of the text.").isFalse(); + } + + @Test + void testAtDeclarationEnd_whenAGreaterThanIsNext_isTrue() + { + final DtdLexer lexer = new DtdLexer("> tail"); + + assertThat(lexer.atDeclarationEnd()).as("The closing angle bracket ends a declaration.").isTrue(); + } + + @Test + void testAtDeclarationEnd_whenAtTheEnd_isTrue() + { + final DtdLexer lexer = new DtdLexer(""); + + assertThat(lexer.atDeclarationEnd()).as("The end of the text ends a declaration.").isTrue(); + } + + @Test + void testAtDeclarationEnd_whenAnotherCharacterIsNext_isFalse() + { + final DtdLexer lexer = new DtdLexer("a>"); + + assertThat(lexer.atDeclarationEnd()).as("An ordinary character does not end a declaration.") + .isFalse(); + } + + @Test + void testAtText_whenTheLiteralStartsAtThePosition_isTrue() + { + final DtdLexer lexer = new DtdLexer("xx"); + lexer.advance(2); + + assertThat(lexer.atText(" tail", DtdLexer::skipComment); + + assertThat(remainder).as("The whole comment, including a greater-than sign, must be skipped.") + .isEqualTo(" tail"); + } + + @Test + void testSkipComment_whenUnclosed_stopsAtTheEnd() + { + final String remainder = remainderAfter("\n\n"); + + assertThat(declarations.tables()).as("The comment must not break parsing of the declarations.") + .extracting(DtdTable::name).containsExactly("USERS"); + } + + @Test + void testRead_whenAParameterEntityIsDeclared_producesOneInfoAndIsIgnored() + { + final DtdDeclarations declarations = + DtdReader.read("\n" + + "\n"); + + assertThat(declarations.getProblems()).as("A parameter entity must produce exactly one info.") + .extracting(DatasetProblem::code).containsExactly(ProblemCode.UNSUPPORTED_DTD_CONSTRUCT); + assertThat(declarations.tables()).as("Parsing must continue after the ignored entity.") + .extracting(DtdTable::name).containsExactly("USERS"); + } + + @Test + void testRead_whenAParameterEntityValueHoldsAGreaterThan_infoCoversTheWholeDeclaration() + { + final String declaration = " b\">"; + + final DtdDeclarations declarations = + DtdReader.read(declaration + "\n\n"); + + assertThat(declarations.getProblems()) + .as("The info must span the declaration up to its real closing bracket.") + .containsExactly(new DatasetProblem(ProblemCode.UNSUPPORTED_DTD_CONSTRUCT, + ProblemSeverity.INFO, Messages.Dtd_parameterEntityDeclarationsIgnored, null, null, -1, + 0, declaration.length())); + } + + @Test + void testRead_whenAConditionalSectionIsDeclared_infoCoversTheSectionAndItsContentIsIgnored() + { + final String prefix = "\n"; + final String section = " ]]>"; + + final DtdDeclarations declarations = DtdReader.read(prefix + section + "\n"); + + assertThat(declarations.getProblems()).as("The info must span exactly the conditional section.") + .containsExactly(new DatasetProblem(ProblemCode.UNSUPPORTED_DTD_CONSTRUCT, + ProblemSeverity.INFO, Messages.Dtd_conditionalSectionsIgnored, null, null, -1, + prefix.length(), section.length())); + assertThat(declarations.missingDeclarations()).as("Nothing inside the section may be declared.") + .isEmpty(); + } + + @Test + void testRead_whenAParameterEntityIsReferenced_infoCoversTheReference() + { + final String prefix = "\n"; + + final DtdDeclarations declarations = + DtdReader.read(prefix + "%common;\n"); + + assertThat(declarations.getProblems()) + .as("The info must span the percent sign through the semicolon.") + .containsExactly(new DatasetProblem(ProblemCode.UNSUPPORTED_DTD_CONSTRUCT, + ProblemSeverity.INFO, Messages.Dtd_parameterEntityReferencesIgnored, null, null, -1, + prefix.length(), "%common;".length())); + } + + @Test + void testRead_whenAParameterEntityReferenceHasNoSemicolon_infoCoversTheNameOnly() + { + final String prefix = "\n"; + + final DtdDeclarations declarations = + DtdReader.read(prefix + "%common\n"); + + assertThat(declarations.getProblems()) + .as("The info must end after the name when no semicolon follows.") + .containsExactly(new DatasetProblem(ProblemCode.UNSUPPORTED_DTD_CONSTRUCT, + ProblemSeverity.INFO, Messages.Dtd_parameterEntityReferencesIgnored, null, null, -1, + prefix.length(), "%common".length())); + } + + @Test + void testRead_whenANotationIsDeclared_isIgnoredWithoutAProblem() + { + final DtdDeclarations declarations = DtdReader.read("\n" + + "\n"); + + assertThat(declarations.getProblems()).as("A notation declaration is skipped silently.").isEmpty(); + assertThat(declarations.tables()).as("Parsing must continue after the notation.") + .extracting(DtdTable::name).containsExactly("USERS"); + } + + @Test + void testRead_whenAProcessingInstructionPrecedesTheDeclarations_isSkipped() + { + final DtdDeclarations declarations = DtdReader.read("\n" + + "\n"); + + assertThat(declarations.getProblems()).as("A processing instruction is skipped silently.").isEmpty(); + assertThat(declarations.tables()).as("Parsing must continue after the processing instruction.") + .extracting(DtdTable::name).containsExactly("USERS"); + } + + @Test + void testRead_whenContentModelIsAny_makesEveryDeclaredElementATableInDeclarationOrder() + { + final DtdDeclarations declarations = DtdReader + .read("\n\n"); + + assertThat(declarations.tables()).as("ANY must make every declared element a table, in order.") + .extracting(DtdTable::name).containsExactly("USERS", "ORDERS"); + } + + @Test + void testRead_whenContentModelIsAnyAndDatasetHasAnAttlist_excludesDatasetFromTables() + { + final DtdDeclarations declarations = DtdReader.read( + "\n\n"); + + assertThat(declarations.tables()).as("The dataset root element must never be listed as a table.") + .extracting(DtdTable::name).containsExactly("USERS"); + } + + @Test + void testRead_whenTheDoctypeNamesAnotherRoot_takesTheContentModelOfThatElement() + { + final String dtd = "\n\n" + + "\n\n"; + + final List rootedAtDataset = DtdReader.read(dtd, "DATASET").tables().stream() + .map(DtdTable::name).toList(); + final List rootedAtLowercase = DtdReader.read(dtd).tables().stream().map(DtdTable::name) + .toList(); + + assertThat(List.of(rootedAtDataset, rootedAtLowercase)) + .as("The element that the DOCTYPE names is the root, and the other one is a table's.") + .containsExactly(List.of("USERS"), List.of("ORDERS")); + } + + @Test + void testRead_whenTheRootNamedByTheDoctypeHasAnAttlist_isNoTable() + { + final DtdDeclarations declarations = DtdReader.read( + "\n\n", "other"); + + assertThat(declarations.tables()).as("The root is never listed as a table.") + .extracting(DtdTable::name).containsExactly("USERS"); + } + + @Test + void testRead_whenContentModelIsEmpty_hasNoTablesAndIsReportedEmpty() + { + final DtdDeclarations declarations = DtdReader.read( + "\n\n"); + + assertThat(List.of(declarations.tables(), declarations.missingDeclarations(), + declarations.hasEmptyContentModel())) + .as("EMPTY is a keyword, not a table name, and dbUnit cannot load such a dataset.") + .containsExactly(List.of(), List.of(), true); + } + + @Test + void testRead_whenContentModelIsNotEmpty_isNotReportedEmpty() + { + final DtdDeclarations declarations = + DtdReader.read("\n"); + + assertThat(List.of(declarations.hasEmptyContentModel(), + declarations.tables().stream().map(DtdTable::name).toList())) + .as("An element that is named EMPTY is a table, because only the whole model is the keyword.") + .containsExactly(false, List.of("EMPTY")); + } + + @Test + void testRead_whenContentModelHasAParameterEntityReference_ignoresItAndReadsTheModelLikeAny() + { + final String prefix = "\n\n"); + + assertThat(declarations.tables()).as("The reference is no table, and the tables it may list are the " + + "declared elements.").extracting(DtdTable::name).containsExactly("USERS", "ORDERS"); + assertThat(declarations.getProblems()).as("The reference must be reported as ignored, where it is.") + .containsExactly(new DatasetProblem(ProblemCode.UNSUPPORTED_DTD_CONSTRUCT, + ProblemSeverity.INFO, Messages.Dtd_parameterEntityReferencesIgnored, null, null, -1, + prefix.length(), "%tables;".length())); + assertThat(declarations.missingDeclarations()) + .as("A content model that the reader cannot read whole names no table without a declaration.") + .isEmpty(); + } + + @Test + void testRead_whenContentModelMixesNamesAndAParameterEntityReference_everyDeclaredElementIsATable() + { + final DtdDeclarations declarations = DtdReader.read("\n" + + "\n\n"); + + assertThat(declarations.tables()).as("The entity may list the other tables.") + .extracting(DtdTable::name).containsExactly("USERS", "ORDERS", "AUDIT"); + } + + @Test + void testRead_whenContentModelHasATokenThatIsNoName_addsNoTableForIt() + { + final DtdDeclarations declarations = DtdReader + .read("\n"); + + assertThat(declarations.tables()).as("Only a name can be a table.").extracting(DtdTable::name) + .containsExactly("USERS"); + } + + @Test + void testMerge_whenTheInternalSubsetHasNoContentModelAndTheExternalOneIsEmpty_isReportedEmpty() + { + final DtdDeclarations internal = DtdReader.read(""); + final DtdDeclarations external = DtdReader.read(""); + + assertThat(List.of(internal.merge(external).hasEmptyContentModel(), + external.merge(internal).hasEmptyContentModel())) + .as("The content model that is declared wins, wherever it is declared.") + .containsExactly(true, true); + } + + @Test + void testMerge_whenBothDeclareAContentModel_theExternalEmptyOneWins() + { + final DtdDeclarations internal = DtdReader.read(""); + final DtdDeclarations external = DtdReader.read(""); + + assertThat(internal.merge(external).hasEmptyContentModel()) + .as("dbUnit takes the last declaration of the root, which is the external DTD's.").isTrue(); + } + + @Test + void testMerge_whenBothDeclareTheContentModel_theExternalNamesWin() + { + final DtdDeclarations internal = DtdReader.read( + "\n\n\n"); + final DtdDeclarations external = DtdReader.read(""); + + assertThat(List.of(internal.merge(external).tables().stream().map(DtdTable::name).toList(), + external.merge(internal).tables().stream().map(DtdTable::name).toList())) + .as("The content model of the DTD that is read last decides which tables there are, in " + + "dbUnit's order of reading, the internal subset first.") + .containsExactly(List.of("B", "C"), List.of("A")); + } + + @Test + void testMerge_whenOnlyTheInternalSubsetDeclaresTheContentModel_itIsKept() + { + final DtdDeclarations internal = DtdReader.read("\n"); + final DtdDeclarations external = DtdReader.read(""); + + assertThat(internal.merge(external).tables()).as("An external DTD without a content model keeps it.") + .extracting(DtdTable::name).containsExactly("A"); + } + + @Test + void testRead_whenTheContentModelIsDeclaredTwice_theLastDeclarationWins() + { + final DtdDeclarations declarations = DtdReader.read( + "\n\n\n"); + + assertThat(declarations.tables()).as("dbUnit takes the last declaration of the root element.") + .extracting(DtdTable::name).containsExactly("B"); + } + + @Test + void testRead_whenAnElementIsDeclaredOutsideTheContentModel_isNotATable() + { + final DtdDeclarations declarations = DtdReader.read( + "\n\n"); + + assertThat(declarations.tables()).as( + "Only the content model's names are tables; AUDIT_LOG is declared but not listed.") + .extracting(DtdTable::name).containsExactly("USERS"); + } + + @Test + void testRead_whenDatasetHasNoContentModel_hasNoTables() + { + final DtdDeclarations declarations = DtdReader.read(""); + + assertThat(declarations.tables()).as("Without a dataset declaration, there are no tables.") + .isEmpty(); + } + + @Test + void testRead_whenAContentModelNameHasNoDeclaration_isATableWithoutColumnsAndIsMissing() + { + final DtdDeclarations declarations = + DtdReader.read("\n"); + + final List tables = declarations.tables(); + + assertThat(tables).as("ORDERS must still be a table.").extracting(DtdTable::name) + .containsExactly("USERS", "ORDERS"); + assertThat(tables.get(1).columns()).as("ORDERS has no declaration, so it has no columns.") + .isEmpty(); + assertThat(declarations.missingDeclarations()) + .as("ORDERS must be reported as a missing declaration.").containsExactly("ORDERS"); + } + + @Test + void testMerge_whenBothHaveAContentModel_takesTheLaterOneAndAppendsLaterColumns() + { + final DtdDeclarations internalSubset = DtdReader + .read("\n"); + final DtdDeclarations externalDtd = DtdReader.read( + "\n\n"); + + final DtdDeclarations merged = internalSubset.merge(externalDtd); + + assertThat(merged.tables()).as("The last (external DTD) content model must win, which is ANY here.") + .extracting(DtdTable::name).containsExactly("USERS", "ORDERS"); + assertThat(merged.tables().get(0).columns()) + .as("Columns must merge by element name, the internal subset's first, then the " + + "external DTD's, without duplicates.") + .containsExactly("ID", "NAME"); + } + + @Test + void testMerge_whenAnElementIsDeclaredInBoth_doesNotDuplicateAColumn() + { + final DtdDeclarations internalSubset = + DtdReader.read("\n"); + final DtdDeclarations externalDtd = DtdReader + .read("\n"); + + final DtdDeclarations merged = internalSubset.merge(externalDtd); + + assertThat(merged.tables().get(0).columns()).as("A column declared in both must appear once.") + .containsExactly("ID", "NAME"); + } + + @Test + void testRead_whenAttributesHaveDefaultAndFixedValues_recordsThemOnTheirColumns() + { + final DtdDeclarations declarations = DtdReader.read("\n" + + "\n"); + + assertThat(declarations.tables()) + .as("Only the columns declared with a default or a fixed value may have a default.") + .containsExactly(new DtdTable("USERS", List.of("ID", "NAME", "STATUS", "KIND"), + Map.of("STATUS", "ACTIVE", "KIND", "A"))); + } + + @Test + void testRead_whenADefaultIsTheEmptyString_recordsTheEmptyString() + { + final DtdDeclarations declarations = DtdReader.read( + "\n"); + + assertThat(declarations.tables()) + .as("An empty default is a value, not an absent one: dbUnit loads an empty string.") + .containsExactly(new DtdTable("USERS", List.of("ID", "STATUS"), Map.of("STATUS", ""))); + } + + @Test + void testRead_whenADefaultHasReferencesAndWhitespace_decodesItLikeAnAttributeValue() + { + final DtdDeclarations declarations = DtdReader.read("\n" + + ""); + + assertThat(declarations.tables()).as("Character references and the predefined entities must " + + "decode, a line feed written as a reference must stay one, and a literal tab must " + + "become a space.") + .containsExactly(new DtdTable("USERS", List.of("NOTE"), Map.of("NOTE", "a&b\n"); + + assertThat(declarations.tables()).as("A single-quoted default may hold a double quote.") + .containsExactly(new DtdTable("USERS", List.of("NOTE"), Map.of("NOTE", "it\"s"))); + } + + @Test + void testRead_whenADefaultUsesAnEntityReference_hasNoDefaultAndReportsAnInfoAtTheValue() + { + final String prefix = "\n"); + + assertThat(declarations.tables()).as("A default the editor cannot decode must not be shown.") + .containsExactly(new DtdTable("USERS", List.of("NOTE"))); + final String reason = NLS.bind(Messages.Codec_unsupportedEntity, "active"); + assertThat(declarations.getProblems()).as("The info must cover the default value's literal.") + .containsExactly(new DatasetProblem(ProblemCode.UNSUPPORTED_DTD_CONSTRUCT, + ProblemSeverity.INFO, NLS.bind(Messages.Dtd_attributeDefaultNotShown, + new Object[] { "NOTE", "USERS", reason }), + null, null, -1, prefix.length(), literal.length())); + } + + @Test + void testRead_whenAnAttributeIsDeclaredTwiceInOneAttlist_theFirstDeclarationWins() + { + final DtdDeclarations declarations = DtdReader.read("\n" + + ""); + + assertThat(declarations.tables()).as("The first declaration of an attribute is binding in XML.") + .containsExactly(new DtdTable("USERS", List.of("STATUS"), Map.of("STATUS", "FIRST"))); + } + + @Test + void testRead_whenALaterAttlistGivesADefaultToAnAttributeDeclaredWithoutOne_theFirstDeclarationWins() + { + final DtdDeclarations declarations = DtdReader.read("\n" + + "\n"); + + assertThat(declarations.tables()) + .as("The earlier declaration without a default is binding, so the column has none.") + .containsExactly(new DtdTable("USERS", List.of("STATUS"))); + } + + @Test + void testMerge_whenBothDeclareTheSameColumnWithDefaults_keepsTheInternalSubsetsDefault() + { + final DtdDeclarations internalSubset = DtdReader + .read("\n"); + final DtdDeclarations externalDtd = + DtdReader.read(""); + + final DtdDeclarations merged = internalSubset.merge(externalDtd); + + assertThat(merged.tables()).as("The internal subset is read first, so its declaration is binding, " + + "and the external DTD's other columns keep their defaults.") + .containsExactly(new DtdTable("USERS", List.of("STATUS", "ID"), + Map.of("STATUS", "INTERNAL", "ID", "0"))); + } + + @Test + void testMerge_whenTheInternalSubsetDeclaresAColumnWithoutADefault_dropsTheExternalDefault() + { + final DtdDeclarations internalSubset = DtdReader + .read("\n"); + final DtdDeclarations externalDtd = DtdReader.read(""); + + final DtdDeclarations merged = internalSubset.merge(externalDtd); + + assertThat(merged.tables()) + .as("The internal subset's declaration without a default is binding.") + .containsExactly(new DtdTable("USERS", List.of("STATUS"))); + } + + @Test + void testExternalElementNames_whenTheDeclarationsWereNotMerged_isEmpty() + { + final DtdDeclarations declarations = DtdReader.read("\n" + + "\n"); + + assertThat(declarations.externalElementNames()) + .as("Declarations that no external DTD was merged into have no external element.") + .isEmpty(); + } + + @Test + void testMerge_whenTheExternalDtdDeclaresOrListsElements_namesThemAsExternal() + { + final DtdDeclarations internalSubset = DtdReader.read("\n" + + "\n"); + final DtdDeclarations externalDtd = DtdReader.read("\n" + + "\n"); + + final DtdDeclarations merged = internalSubset.merge(externalDtd); + + assertThat(merged.externalElementNames()) + .as("An element that the external DTD declares with ELEMENT or ATTLIST, or lists in its " + + "content model, is external, and USERS, which only the internal subset " + + "declares, is not.") + .containsExactlyInAnyOrder("ORDERS", "ITEMS", "PETS"); + } + + @Test + void testRelocatingProblems_whenTheDeclarationsWereMerged_keepsTheExternalElementNames() + { + final DtdDeclarations merged = DtdReader.read("") + .merge(DtdReader.read("")); + + assertThat(merged.withProblemsShiftedBy(10).externalElementNames()) + .as("Shifting the problems must not lose the external elements.").containsExactly("USERS"); + assertThat(merged.withProblemsAt(3, 4).externalElementNames()) + .as("Relocating the problems must not lose the external elements.") + .containsExactly("USERS"); + } + + @Test + void testWithProblemsShiftedBy_whenColumnsHaveDefaults_keepsTheDefaults() + { + final DtdDeclarations declarations = DtdReader + .read("\n"); + + assertThat(declarations.withProblemsShiftedBy(10).tables()) + .as("Relocating the problems must not lose the defaults.") + .isEqualTo(declarations.tables()); + } + + @Test + void testWithProblemsAt_whenColumnsHaveDefaults_keepsTheDefaults() + { + final DtdDeclarations declarations = DtdReader + .read("\n"); + + assertThat(declarations.withProblemsAt(3, 4).tables()) + .as("Relocating the problems must not lose the defaults.") + .isEqualTo(declarations.tables()); + } + + @Test + void testLocateElementNames_whenTheDtdDeclaresTables_findsEachNameInTheOrderTheTextWritesIt() + { + final String dtd = "\n\n" + + "\n"; + + final List names = DtdReader.locateElementNames(dtd); + + assertThat(names).as("The content model lists the tables first, then each declaration names its " + + "element.") + .containsExactly(new DtdElementName("USERS", dtd.indexOf("USERS*")), + new DtdElementName("ORDERS", dtd.indexOf("ORDERS*")), + new DtdElementName("USERS", dtd.indexOf("USERS EMPTY")), + new DtdElementName("USERS", dtd.indexOf("USERS ID")), + new DtdElementName("ORDERS", dtd.indexOf("ORDERS EMPTY"))); + } + + @Test + void testLocateElementNames_whenSeveralAttlistsNameTheElement_findsEachOne() + { + final String dtd = "\n"; + + assertThat(DtdReader.locateElementNames(dtd)) + .as("Every ATTLIST declaration of the element writes its name.") + .containsExactly(new DtdElementName("USERS", dtd.indexOf("USERS ID")), + new DtdElementName("USERS", dtd.indexOf("USERS NAME"))); + } + + @Test + void testLocateElementNames_whenTheContentModelIsNestedOrAChoice_findsEachNameWhereItIsWritten() + { + final String dtd = ""; + + assertThat(DtdReader.locateElementNames(dtd)) + .as("A name that the model writes twice is found twice, and no structural character is " + + "part of a name.") + .containsExactly(new DtdElementName("A", dtd.indexOf("A|")), + new DtdElementName("B", dtd.indexOf("B)")), + new DtdElementName("C", dtd.indexOf("C?")), + new DtdElementName("A", dtd.indexOf("A)"))); + } + + @Test + void testLocateElementNames_whenTheContentModelHasPcdataAndNamesWithACommonPrefix_findsOnlyNames() + { + final String dtd = ""; + + assertThat(DtdReader.locateElementNames(dtd)) + .as("#PCDATA is no element name, and a name is found whole, not as the prefix of another.") + .containsExactly(new DtdElementName("USERS", dtd.indexOf("USERS |")), + new DtdElementName("USERS_AUDIT", dtd.indexOf("USERS_AUDIT"))); + } + + @Test + void testLocateElementNames_whenTheContentModelHasAParameterEntityReference_findsOnlyTheNames() + { + final String dtd = "\n"; + + assertThat(DtdReader.locateElementNames(dtd)) + .as("A parameter entity reference is no element name, so it is not renamed.") + .containsExactly(new DtdElementName("USERS", dtd.indexOf("USERS")), + new DtdElementName("USERS", dtd.lastIndexOf("USERS"))); + } + + @Test + void testLocateElementNames_whenTheDoctypeNamesAnotherRoot_findsTheNamesOfItsContentModel() + { + final String dtd = "\n"; + + assertThat(DtdReader.locateElementNames(dtd, "DATASET")) + .as("The name of the root is not an element name to rename, but the names in its content " + + "model are.") + .containsExactly(new DtdElementName("USERS", dtd.indexOf("USERS")), + new DtdElementName("USERS", dtd.lastIndexOf("USERS"))); + } + + @Test + void testLocateElementNames_whenOnlyTheDatasetElementIsDeclared_findsNoName() + { + final String dtd = "\n"; + + assertThat(DtdReader.locateElementNames(dtd)) + .as("The dataset element is never a table, so its own name is not one to rename.") + .isEmpty(); + } + + @Test + void testLocateElementNames_whenANameIsInACommentAnEntityOrADefaultValue_isNotFound() + { + final String dtd = "\n\n" + + "\n"; + + assertThat(DtdReader.locateElementNames(dtd)) + .as("Only the names that declarations give their elements are found, not text that " + + "happens to spell one.") + .containsExactly(new DtdElementName("ORDERS", dtd.indexOf("ORDERS EMPTY")), + new DtdElementName("ORDERS", dtd.indexOf("ORDERS KIND"))); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/dtd/DtdTableTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/dtd/DtdTableTest.java new file mode 100644 index 00000000..47e8974e --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/dtd/DtdTableTest.java @@ -0,0 +1,90 @@ +/* + * + * 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.eclipse.dataset.core.dtd; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +/** + * Tests that {@link DtdTable} keeps its column list and its defaults to itself. + */ +class DtdTableTest +{ + @Test + void testConstructor_whenTheGivenListChangesAfterwards_keepsTheColumnsItWasGiven() + { + final List columns = new ArrayList<>(List.of("ID", "NAME")); + final DtdTable table = new DtdTable("USERS", columns); + + columns.add("EXTRA"); + + assertThat(table).as("A later change to the caller's list must not change the table.") + .isEqualTo(new DtdTable("USERS", List.of("ID", "NAME"))); + } + + @Test + void testColumns_whenTheReturnedListIsModified_throws() + { + final DtdTable table = new DtdTable("USERS", new ArrayList<>(List.of("ID", "NAME"))); + + assertThatThrownBy(() -> table.columns().add("EXTRA")) + .as("The returned column list must not let a caller change the table.") + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void testConstructor_whenTheGivenDefaultsChangeAfterwards_keepsTheDefaultsItWasGiven() + { + final Map defaults = new HashMap<>(Map.of("NAME", "x")); + final DtdTable table = new DtdTable("USERS", List.of("ID", "NAME"), defaults); + + defaults.put("ID", "0"); + + assertThat(table).as("A later change to the caller's map must not change the table.") + .isEqualTo(new DtdTable("USERS", List.of("ID", "NAME"), Map.of("NAME", "x"))); + } + + @Test + void testDefaults_whenTheReturnedMapIsModified_throws() + { + final DtdTable table = new DtdTable("USERS", List.of("NAME"), new HashMap<>(Map.of("NAME", "x"))); + + assertThatThrownBy(() -> table.defaults().put("ID", "0")) + .as("The returned map must not let a caller change the table.") + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void testConstructor_whenGivenNoDefaults_isEqualToATableWithAnEmptyDefaultsMap() + { + final DtdTable table = new DtdTable("USERS", List.of("ID")); + + assertThat(table).as("A table created without defaults must have none.") + .isEqualTo(new DtdTable("USERS", List.of("ID"), Map.of())); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/edit/TableChangesTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/edit/TableChangesTest.java new file mode 100644 index 00000000..1d4f5dab --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/edit/TableChangesTest.java @@ -0,0 +1,303 @@ +/* + * + * 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.eclipse.dataset.core.edit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.dbunit.eclipse.dataset.core.model.DatasetModel; +import org.dbunit.eclipse.dataset.core.model.DatasetRow; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link TableChanges}: which tables of a model are tables of the model before it under other keys, + * and which are new. + */ +class TableChangesTest +{ + private static DatasetTable table(final String key, final List columnNames, + final List> rowValues) + { + final List columns = new ArrayList<>(); + for (final String columnName : columnNames) + { + columns.add(new DatasetColumn(columnName, false, true, false)); + } + final List rows = new ArrayList<>(); + for (final List values : rowValues) + { + rows.add(new DatasetRow(values)); + } + return new DatasetTable(key, key, columns, rows, false); + } + + private static DatasetTable table(final String key) + { + return table(key, List.of("ID"), List.of(List.of("1"))); + } + + private static DatasetModel model(final DatasetTable... tables) + { + return new DatasetModel(List.of(tables), List.of(), true); + } + + @Test + void testBetween_whenATableHasAnotherKeyAndTheSameContent_isRenamed() + { + final DatasetModel before = model(table("USERS"), table("ORDERS")); + final DatasetModel after = model(table("CUSTOMERS"), table("ORDERS")); + + final TableChanges changes = TableChanges.between(before, after); + + assertThat(changes).as("The table under the new key is the old table, renamed.") + .isEqualTo(new TableChanges(Map.of("USERS", "CUSTOMERS"), List.of())); + } + + @Test + void testBetween_whenTheSameKeyIsInBothModels_isNeitherRenamedNorAdded() + { + final DatasetModel before = model(table("USERS", List.of("ID"), List.of(List.of("1")))); + final DatasetModel after = model(table("USERS", List.of("ID", "NAME"), List.of(List.of("2", "Bob")))); + + final TableChanges changes = TableChanges.between(before, after); + + assertThat(changes).as("A key that both models have is the same table, whatever changed in it.") + .isEqualTo(TableChanges.NONE); + } + + @Test + void testBetween_whenTheTableUnderAnotherKeyHasOtherValues_isAddedNotRenamed() + { + final DatasetModel before = model(table("USERS", List.of("ID"), List.of(List.of("1")))); + final DatasetModel after = model(table("CUSTOMERS", List.of("ID"), List.of(List.of("2")))); + + final TableChanges changes = TableChanges.between(before, after); + + assertThat(changes).as("A table with other values is another table.") + .isEqualTo(new TableChanges(Map.of(), List.of("CUSTOMERS"))); + } + + @Test + void testBetween_whenTheTableUnderAnotherKeyHasOtherColumns_isAddedNotRenamed() + { + final DatasetModel before = model(table("USERS", List.of("ID"), List.of(List.of("1")))); + final DatasetModel after = model(table("CUSTOMERS", List.of("NUMBER"), List.of(List.of("1")))); + + final TableChanges changes = TableChanges.between(before, after); + + assertThat(changes).as("A table with other columns is another table, whatever its values are.") + .isEqualTo(new TableChanges(Map.of(), List.of("CUSTOMERS"))); + } + + @Test + void testBetween_whenTheTableUnderAnotherKeyHasMoreRows_isAddedNotRenamed() + { + final DatasetModel before = model(table("USERS", List.of("ID"), List.of(List.of("1")))); + final DatasetModel after = + model(table("CUSTOMERS", List.of("ID"), List.of(List.of("1"), List.of("2")))); + + final TableChanges changes = TableChanges.between(before, after); + + assertThat(changes).as("A table with more rows is another table.") + .isEqualTo(new TableChanges(Map.of(), List.of("CUSTOMERS"))); + } + + @Test + void testBetween_whenTheTableUnderAnotherKeyHasOtherColumnsInAnotherOrder_isAddedNotRenamed() + { + final DatasetModel before = + model(table("USERS", List.of("ID", "NAME"), List.of(List.of("1", "Alice")))); + final DatasetModel after = + model(table("CUSTOMERS", List.of("NAME", "ID"), List.of(List.of("1", "Alice")))); + + final TableChanges changes = TableChanges.between(before, after); + + assertThat(changes).as("The columns of a table are in order, so another order is another table.") + .isEqualTo(new TableChanges(Map.of(), List.of("CUSTOMERS"))); + } + + @Test + void testBetween_whenOnlyTheFlagsOfTheColumnsDiffer_isStillRenamed() + { + final DatasetTable declared = new DatasetTable("USERS", "USERS", + List.of(new DatasetColumn("ID", true, true, false)), List.of(new DatasetRow(List.of("1"))), + false); + final DatasetTable plain = table("CUSTOMERS"); + + final TableChanges changes = TableChanges.between(model(declared), model(plain)); + + assertThat(changes).as("The names of the columns and the rows decide, not what declares the columns.") + .isEqualTo(new TableChanges(Map.of("USERS", "CUSTOMERS"), List.of())); + } + + @Test + void testBetween_whenAValueIsNull_comparesItAsAValue() + { + final DatasetModel before = + model(table("USERS", List.of("ID", "NAME"), List.of(Arrays.asList("1", null)))); + final DatasetModel sameAfter = + model(table("CUSTOMERS", List.of("ID", "NAME"), List.of(Arrays.asList("1", null)))); + final DatasetModel emptyStringAfter = + model(table("CUSTOMERS", List.of("ID", "NAME"), List.of(Arrays.asList("1", "")))); + + final TableChanges forNull = TableChanges.between(before, sameAfter); + final TableChanges forEmptyString = TableChanges.between(before, emptyStringAfter); + + assertThat(List.of(forNull, forEmptyString)).as("NULL equals NULL and is not the empty string.") + .containsExactly(new TableChanges(Map.of("USERS", "CUSTOMERS"), List.of()), + new TableChanges(Map.of(), List.of("CUSTOMERS"))); + } + + @Test + void testBetween_whenTwoTablesHaveTheSameContentAndBothAreRenamed_pairsThemInOrder() + { + final DatasetModel before = model(table("A"), table("B")); + final DatasetModel after = model(table("X"), table("Y")); + + final TableChanges changes = TableChanges.between(before, after); + + final Map expected = new LinkedHashMap<>(); + expected.put("A", "X"); + expected.put("B", "Y"); + assertThat(changes).as("Tables that cannot be told apart are paired in the order of the models.") + .isEqualTo(new TableChanges(expected, List.of())); + } + + @Test + void testBetween_whenOneNewTableMatchesTwoGoneTables_isTheRenameOfTheFirstOnly() + { + final DatasetModel before = model(table("A"), table("B")); + final DatasetModel after = model(table("X")); + + final TableChanges changes = TableChanges.between(before, after); + + assertThat(changes).as("A new table is the rename of one old table, the first that it matches.") + .isEqualTo(new TableChanges(Map.of("A", "X"), List.of())); + } + + @Test + void testBetween_whenOneGoneTableMatchesTwoNewTables_isRenamedToTheFirstAndTheOtherIsAdded() + { + final DatasetModel before = model(table("A")); + final DatasetModel after = model(table("X"), table("Y")); + + final TableChanges changes = TableChanges.between(before, after); + + assertThat(changes).as("The first new table that matches is the rename, and the other one is new.") + .isEqualTo(new TableChanges(Map.of("A", "X"), List.of("Y"))); + } + + @Test + void testBetween_whenSeveralTablesWereAdded_listsTheirKeysInTheOrderOfTheNewModel() + { + final DatasetModel before = model(table("USERS", List.of("ID"), List.of(List.of("1")))); + final DatasetModel after = model(table("B", List.of("B"), List.of()), + table("USERS", List.of("ID"), List.of(List.of("1"))), table("A", List.of("A"), List.of())); + + final TableChanges changes = TableChanges.between(before, after); + + assertThat(changes.addedKeys()).as("The keys must come in the order of the new model.") + .containsExactly("B", "A"); + } + + @Test + void testBetween_whenTheOldModelHasNoTables_addsEveryTable() + { + final DatasetModel after = model(table("USERS"), table("ORDERS")); + + final TableChanges changes = TableChanges.between(DatasetModel.EMPTY, after); + + assertThat(changes).as("Every table of the new model is new.") + .isEqualTo(new TableChanges(Map.of(), List.of("USERS", "ORDERS"))); + } + + @Test + void testBetween_whenTheNewModelHasNoTables_changesNothing() + { + final TableChanges changes = TableChanges.between(model(table("USERS")), DatasetModel.EMPTY); + + assertThat(changes).as("A table that is gone is neither renamed nor added.") + .isEqualTo(TableChanges.NONE); + } + + @Test + void testBetween_whenBothModelsAreTheSame_changesNothing() + { + final DatasetModel same = model(table("USERS"), table("ORDERS")); + + assertThat(TableChanges.between(same, same)).as("Nothing changed.").isEqualTo(TableChanges.NONE); + } + + @Test + void testBetween_whenAModelHasTwoTablesWithOneKey_countsTheFirstOnly() + { + final DatasetModel before = model(table("USERS", List.of("ID"), List.of(List.of("1")))); + final DatasetModel after = model(table("CUSTOMERS", List.of("ID"), List.of(List.of("1"))), + table("CUSTOMERS", List.of("ID"), List.of(List.of("2")))); + + final TableChanges changes = TableChanges.between(before, after); + + assertThat(changes).as("Of two tables with one key, the first is the one that is shown.") + .isEqualTo(new TableChanges(Map.of("USERS", "CUSTOMERS"), List.of())); + } + + @Test + void testConstructor_whenTheGivenCollectionsChangeAfterwards_keepsWhatItWasGiven() + { + final Map renamedKeys = new LinkedHashMap<>(Map.of("A", "X")); + final List addedKeys = new ArrayList<>(List.of("Y")); + final TableChanges changes = new TableChanges(renamedKeys, addedKeys); + + renamedKeys.put("B", "Z"); + addedKeys.add("W"); + + assertThat(changes).as("A later change of the given collections must not change the changes.") + .isEqualTo(new TableChanges(Map.of("A", "X"), List.of("Y"))); + } + + @Test + void testAccessors_whenAReturnedCollectionIsModified_throws() + { + final TableChanges changes = new TableChanges(Map.of("A", "X"), List.of("Y")); + + assertThatThrownBy(() -> changes.renamedKeys().put("B", "Z")) + .as("The returned renames must not let a caller change the changes.") + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> changes.addedKeys().add("W")) + .as("The returned added keys must not let a caller change the changes.") + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void testNone_whenRead_hasNoRenamedAndNoAddedTable() + { + assertThat(List.of(TableChanges.NONE.renamedKeys().size(), TableChanges.NONE.addedKeys().size())) + .as("NONE must have no renames and no added tables.").containsExactly(0, 0); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/AttributeValueCodecTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/AttributeValueCodecTest.java new file mode 100644 index 00000000..4013e8a0 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/AttributeValueCodecTest.java @@ -0,0 +1,432 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowableOfType; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharsetEncoder; +import java.nio.charset.CoderResult; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +import org.dbunit.eclipse.dataset.core.edit.DatasetEditException; +import org.junit.jupiter.api.Test; + +/** + * Tests decoding and escaping of dbUnit flat XML attribute values: predefined entities, character + * references, whitespace normalization, and escaping on write. + */ +class AttributeValueCodecTest +{ + @Test + void testDecode_whenGivenPlainText_returnsItUnchanged() throws AttributeValueException + { + assertThat(AttributeValueCodec.decode("Alice Smith, 42 > 7")) + .as("Text without references or literal whitespace other than spaces must decode to itself.") + .isEqualTo("Alice Smith, 42 > 7"); + } + + @Test + void testDecode_whenPlainTextPrecedesTextToDecode_keepsThePlainTextAndDecodesTheRest() + throws AttributeValueException + { + assertThat(AttributeValueCodec.decode("Tom & Jerry\tsay <hi>")) + .as("The plain text before the first reference must be kept, and the rest decoded.") + .isEqualTo("Tom & Jerry say "); + } + + @Test + void testDecode_whenGivenEachPredefinedEntity_returnsItsCharacter() throws AttributeValueException + { + assertThat(AttributeValueCodec.decode("<>&"'")) + .as("The five predefined entities must decode to their characters.").isEqualTo("<>&\"'"); + } + + @Test + void testDecode_whenGivenDecimalAndHexadecimalReferences_returnsTheirCharacter() + throws AttributeValueException + { + assertThat(AttributeValueCodec.decode("A")) + .as("A decimal reference must decode by code point.").isEqualTo("A"); + assertThat(AttributeValueCodec.decode("A")) + .as("A hexadecimal reference must decode by code point.").isEqualTo("A"); + } + + @Test + void testDecode_whenAHexadecimalReferenceUsesAsciiLettersOfEitherCase_returnsTheirCharacter() + throws AttributeValueException + { + assertThat(AttributeValueCodec.decode("J")) + .as("A hexadecimal reference must accept the lower-case letters a to f.").isEqualTo("J"); + assertThat(AttributeValueCodec.decode("J")) + .as("A hexadecimal reference must accept the upper-case letters A to F.").isEqualTo("J"); + } + + @Test + void testDecode_whenADecimalReferenceUsesNonAsciiDigits_throwsAtItsOffset() + { + final AttributeValueException exception = catchThrowableOfType( + () -> AttributeValueCodec.decode("a&#\u0666\u0665;b"), AttributeValueException.class); + + assertThat(exception) + .as("Arabic-Indic digits are not XML decimal digits, so the reference must throw.") + .isNotNull(); + assertThat(exception.getOffset()).as("The offset must point at the '&'.").isEqualTo(1); + assertThat(exception.getMessage()).as("The message must quote the digits.") + .isEqualTo("'\u0666\u0665' is not a valid character reference."); + } + + @Test + void testDecode_whenAHexadecimalReferenceUsesAFullwidthLetter_throwsAtItsOffset() + { + final AttributeValueException exception = catchThrowableOfType( + () -> AttributeValueCodec.decode("&#x\uFF21;"), AttributeValueException.class); + + assertThat(exception) + .as("A fullwidth A is not an XML hexadecimal digit, so the reference must throw.") + .isNotNull(); + assertThat(exception.getOffset()).as("The offset must point at the '&'.").isEqualTo(0); + } + + @Test + void testDecode_whenGivenLiteralTabOrLineFeed_returnsASpace() throws AttributeValueException + { + assertThat(AttributeValueCodec.decode("\t")).as("A literal tab must normalize to a space.") + .isEqualTo(" "); + assertThat(AttributeValueCodec.decode("\n")).as("A literal line feed must normalize to a space.") + .isEqualTo(" "); + } + + @Test + void testDecode_whenGivenACarriageReturnLineFeedPair_returnsOneSpace() throws AttributeValueException + { + assertThat(AttributeValueCodec.decode("\r\n")) + .as("A CR LF pair must normalize to exactly one space.").isEqualTo(" "); + } + + @Test + void testDecode_whenGivenALoneCarriageReturn_returnsASpace() throws AttributeValueException + { + assertThat(AttributeValueCodec.decode("\r")) + .as("A lone carriage return must normalize to a space.").isEqualTo(" "); + } + + @Test + void testDecode_whenGivenALineFeedCharacterReference_returnsTheLineFeedItself() + throws AttributeValueException + { + assertThat(AttributeValueCodec.decode(" ")) + .as("A character reference is not literal whitespace, so it must not be normalized.") + .isEqualTo("\n"); + } + + @Test + void testDecode_whenGivenAnUnknownEntity_throwsFlaggedAsUnsupportedAtItsOffset() + { + final AttributeValueException exception = catchThrowableOfType( + () -> AttributeValueCodec.decode("ab&foo;cd"), AttributeValueException.class); + + assertThat(exception).as("An unknown entity must throw AttributeValueException.").isNotNull(); + assertThat(exception.getOffset()).as("The offset must point at the '&'.").isEqualTo(2); + assertThat(exception.isUnsupportedEntity()) + .as("An unknown named entity is unsupported, not malformed.").isTrue(); + assertThat(exception.getMessage()).as("The message must quote the entity reference.") + .isEqualTo("Entity reference '&foo;' is not supported; only the predefined entities and " + + "character references are available without a DTD."); + } + + @Test + void testDecode_whenGivenABareAmpersand_throwsNotFlaggedAsUnsupportedAtItsOffset() + { + final AttributeValueException exception = catchThrowableOfType( + () -> AttributeValueCodec.decode("ab&cd"), AttributeValueException.class); + + assertThat(exception).as("A '&' without a ';' must throw AttributeValueException.").isNotNull(); + assertThat(exception.getOffset()).as("The offset must point at the '&'.").isEqualTo(2); + assertThat(exception.isUnsupportedEntity()) + .as("A malformed reference is not an unsupported entity.").isFalse(); + } + + @Test + void testNotXmlCharacterMessage_forACharacterOfOneDigit_padsItToFourDigits() + { + assertThat(AttributeValueCodec.notXmlCharacterMessage(0xB)) + .as("The code point must be written as U+ and at least four digits.") + .isEqualTo("The character U+000B is not allowed in an XML 1.0 document."); + } + + @Test + void testNotXmlCharacterMessage_forASurrogate_namesItInUpperCase() + { + assertThat(AttributeValueCodec.notXmlCharacterMessage(0xD800)) + .as("The digits must be upper-case hexadecimal.") + .isEqualTo("The character U+D800 is not allowed in an XML 1.0 document."); + } + + @Test + void testNotXmlCharacterMessage_forACharacterBeyondTheBasicPlane_keepsAllItsDigits() + { + assertThat(AttributeValueCodec.notXmlCharacterMessage(0x10FFFF)) + .as("Padding must not cut a code point that has more than four digits.") + .isEqualTo("The character U+10FFFF is not allowed in an XML 1.0 document."); + } + + @Test + void testEscape_whenGivenANonXmlCharacter_saysWhichOneInTheSharedMessage() + { + assertThatThrownBy(() -> AttributeValueCodec.escape("a\u000Bb", StandardCharsets.UTF_8.newEncoder())) + .as("The refusal of a character must use the message that the other places use.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(AttributeValueCodec.notXmlCharacterMessage(0xB)); + } + + @Test + void testDecode_whenGivenAReferenceToANonXmlCharacter_throwsAtItsOffset() + { + final AttributeValueException exception = catchThrowableOfType( + () -> AttributeValueCodec.decode("ab"), AttributeValueException.class); + + assertThat(exception).as("U+0001 is not an XML 1.0 Char, so the reference must throw.") + .isNotNull(); + assertThat(exception.getOffset()).as("The offset must point at the '&'.").isEqualTo(1); + } + + @Test + void testDecode_whenEntireValueIsALiteralNonXmlCharacter_throwsAtItsOffset() + { + final AttributeValueException exception = catchThrowableOfType( + () -> AttributeValueCodec.decode("\u000B"), AttributeValueException.class); + + assertThat(exception).as("U+000B is not an XML 1.0 Char, so a literal one must throw.") + .isNotNull(); + assertThat(exception.getOffset()).as("The offset must point at the character.").isEqualTo(0); + assertThat(exception.getMessage()).as("The refusal must use the message that the other places use.") + .isEqualTo(AttributeValueCodec.notXmlCharacterMessage(0xB)); + } + + @Test + void testDecode_whenALiteralNonXmlCharacterPrecedesAReference_throwsAtItsOffset() + { + final AttributeValueException exception = catchThrowableOfType( + () -> AttributeValueCodec.decode("a\u000B&"), AttributeValueException.class); + + assertThat(exception) + .as("A literal non-XML character before the first reference must still throw.") + .isNotNull(); + assertThat(exception.getOffset()).as("The offset must point at the character.").isEqualTo(1); + } + + @Test + void testDecode_whenALiteralNonXmlCharacterFollowsADecodedReference_throwsAtItsOffset() + { + final AttributeValueException exception = catchThrowableOfType( + () -> AttributeValueCodec.decode("&\u000B"), AttributeValueException.class); + + assertThat(exception).as("A literal non-XML character after a reference must still throw.") + .isNotNull(); + assertThat(exception.getOffset()).as("The offset must point at the character.").isEqualTo(5); + } + + @Test + void testEscape_whenGivenEachSpecialCharacter_matchesS11Exactly() + { + assertThat(AttributeValueCodec.escape("&", null)).as("'&' must escape to &.") + .isEqualTo("&"); + assertThat(AttributeValueCodec.escape("<", null)).as("'<' must escape to <.") + .isEqualTo("<"); + assertThat(AttributeValueCodec.escape(">", null)).as("'>' must escape to >.") + .isEqualTo(">"); + assertThat(AttributeValueCodec.escape("\"", null)).as("'\"' must escape to ".") + .isEqualTo("""); + assertThat(AttributeValueCodec.escape("'", null)).as("''' must escape to '.") + .isEqualTo("'"); + assertThat(AttributeValueCodec.escape("\t", null)).as("A tab must escape to .") + .isEqualTo(" "); + assertThat(AttributeValueCodec.escape("\n", null)).as("A line feed must escape to .") + .isEqualTo(" "); + assertThat(AttributeValueCodec.escape("\r", null)).as("A carriage return must escape to .") + .isEqualTo(" "); + } + + @Test + void testEscape_whenEncoderCannotEncodeTheCharacter_usesAHexadecimalReference() + { + final CharsetEncoder encoder = StandardCharsets.ISO_8859_1.newEncoder(); + + assertThat(AttributeValueCodec.escape("€", encoder)) + .as("'€' is not encodable in ISO-8859-1, so it must become €.") + .isEqualTo("€"); + } + + @Test + void testEscape_whenEncoderCanEncodeTheCharacter_staysLiteral() + { + final CharsetEncoder encoder = StandardCharsets.UTF_8.newEncoder(); + + assertThat(AttributeValueCodec.escape("€", encoder)) + .as("'€' is encodable in UTF-8, so it must stay literal.").isEqualTo("€"); + } + + @Test + void testEscape_whenGivenASupplementaryCharacterTheEncoderCannotEncode_usesOneHexadecimalReference() + { + final CharsetEncoder encoder = StandardCharsets.ISO_8859_1.newEncoder(); + final String emoji = new String(Character.toChars(0x1F600)); + + assertThat(AttributeValueCodec.escape(emoji, encoder)) + .as("An emoji must escape to exactly one character reference.").isEqualTo("😀"); + } + + @Test + void testEscape_whenThereIsNoEncoder_keepsEveryCharacterLiteral() + { + final String emoji = new String(Character.toChars(0x1F600)); + + assertThat(AttributeValueCodec.escape("abc€" + emoji, null)) + .as("Without an encoder every character is encodable, so none becomes a reference.") + .isEqualTo("abc€" + emoji); + } + + @Test + void testEscape_whenGivenCharactersOfTheBasicPlane_asksTheEncoderAboutEachAsACharNotAString() + { + final RecordingEncoder encoder = new RecordingEncoder(StandardCharsets.ISO_8859_1.newEncoder()); + final String emoji = new String(Character.toChars(0x1F600)); + + final String escaped = AttributeValueCodec.escape("ab€" + emoji, encoder); + + assertThat(escaped).as("The text must be escaped as before.").isEqualTo("ab€😀"); + assertThat(encoder.charQuestions) + .as("A character of the basic plane is one char, which asks without building a string.") + .containsExactly('a', 'b', (char) 0x20AC); + assertThat(encoder.textQuestions) + .as("Only a supplementary character is two chars, which the encoder gets as text.") + .containsExactly(emoji); + } + + @Test + void testEscape_whenValueContainsANonXmlCharacter_throwsDatasetEditException() + { + final DatasetEditException exception = catchThrowableOfType( + () -> AttributeValueCodec.escape("\u0001", null), DatasetEditException.class); + + assertThat(exception).as("U+0001 is not an XML 1.0 Char, so escaping it must be rejected.") + .isNotNull(); + } + + @Test + void testDecodeOfEscape_whenGivenManyRandomStrings_roundTripsExactly() throws AttributeValueException + { + final int[] pool = buildRoundTripCodePointPool(); + final CharsetEncoder encoder = StandardCharsets.UTF_8.newEncoder(); + final Random random = new Random(42); + for (int i = 0; i < 1000; i++) + { + final String value = randomString(pool, random); + final String escaped = AttributeValueCodec.escape(value, encoder); + final String decoded = AttributeValueCodec.decode(escaped); + assertThat(decoded).as("Round-tripping '" + value + "' must return the original value.") + .isEqualTo(value); + } + } + + private static int[] buildRoundTripCodePointPool() + { + final List pool = new ArrayList<>(); + for (char letter = 'a'; letter <= 'z'; letter++) + { + pool.add((int) letter); + } + for (char letter = 'A'; letter <= 'Z'; letter++) + { + pool.add((int) letter); + } + for (char digit = '0'; digit <= '9'; digit++) + { + pool.add((int) digit); + } + pool.addAll(List.of((int) '<', (int) '>', (int) '&', (int) '"', (int) '\'', (int) ' ', + (int) '\t', (int) '\n', (int) '\r')); + pool.addAll(List.of(0x1F600, 0x10000, 0x20AC)); + final int[] result = new int[pool.size()]; + for (int i = 0; i < result.length; i++) + { + result[i] = pool.get(i); + } + return result; + } + + /** + * A charset encoder that writes down what it is asked whether it can encode, and answers as the encoder + * that it wraps does. It never encodes anything itself. + */ + private static final class RecordingEncoder extends CharsetEncoder + { + private final CharsetEncoder delegate; + + private final List charQuestions = new ArrayList<>(); + + private final List textQuestions = new ArrayList<>(); + + RecordingEncoder(final CharsetEncoder delegate) + { + super(delegate.charset(), delegate.averageBytesPerChar(), delegate.maxBytesPerChar()); + this.delegate = delegate; + } + + @Override + protected CoderResult encodeLoop(final CharBuffer in, final ByteBuffer out) + { + throw new UnsupportedOperationException("The encoder only records what it is asked."); + } + + @Override + public boolean canEncode(final char c) + { + charQuestions.add(c); + return delegate.canEncode(c); + } + + @Override + public boolean canEncode(final CharSequence text) + { + textQuestions.add(text.toString()); + return delegate.canEncode(text); + } + } + + private static String randomString(final int[] pool, final Random random) + { + final int length = random.nextInt(20) + 1; + final StringBuilder value = new StringBuilder(); + for (int i = 0; i < length; i++) + { + value.appendCodePoint(pool[random.nextInt(pool.length)]); + } + return value.toString(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/CellEditsTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/CellEditsTest.java new file mode 100644 index 00000000..be802d4a --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/CellEditsTest.java @@ -0,0 +1,283 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.dbunit.eclipse.dataset.core.Messages; +import org.dbunit.eclipse.dataset.core.edit.CellChange; +import org.dbunit.eclipse.dataset.core.edit.DatasetEditException; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.eclipse.osgi.util.NLS; +import org.eclipse.text.edits.TextEdit; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link CellEdits}: the edits that it plans for each kind of change, applied to the text, and the + * changes that it refuses. + */ +class CellEditsTest +{ + private static final String TEXT = "\n" + " \n" + + " \n" + "\n"; + + private static List plan(final ParsedDataset parsed, final CellChange... changes) + { + final DatasetTable table = parsed.model().findTable("USERS").orElseThrow(); + return new CellEdits(parsed.editContext()).plan("USERS", table, List.of(changes)); + } + + private static String result(final ParsedDataset parsed, final CellChange... changes) throws Exception + { + return parsed.apply(plan(parsed, changes)); + } + + @Test + void testPlan_whenACellChanges_rewritesTheStartTagOfItsRow() throws Exception + { + final String result = result(ParsedDataset.of(TEXT), new CellChange(0, "NAME", "Bobby")); + + assertThat(result).as("Only the value of the changed cell must differ.") + .isEqualTo(TEXT.replace("NAME=\"Bob\"", "NAME=\"Bobby\"")); + } + + @Test + void testPlan_whenCellsOfTwoRowsChange_rewritesBothStartTags() throws Exception + { + final ParsedDataset parsed = ParsedDataset.of(TEXT); + + final List edits = + plan(parsed, new CellChange(0, "NAME", "Bobby"), new CellChange(1, "NAME", "Alicia")); + + assertThat(parsed.apply(edits)).as("Both rows must change.") + .isEqualTo(TEXT.replace("\"Bob\"", "\"Bobby\"").replace("\"Alice\"", "\"Alicia\"")); + assertThat(edits).as("There must be one edit for each changed row.").hasSize(2); + } + + @Test + void testPlan_whenTwoCellsOfOneRowChange_makesOneEditForTheRow() throws Exception + { + final ParsedDataset parsed = ParsedDataset.of(TEXT); + + final List edits = + plan(parsed, new CellChange(0, "NAME", "x"), new CellChange(0, "EMAIL", "y")); + + assertThat(parsed.apply(edits)).as("Both cells must change.") + .isEqualTo(TEXT.replace("\"Bob\"", "\"x\"").replace("\"b@x.org\"", "\"y\"")); + assertThat(edits).as("A row is rewritten by one edit, however many of its cells change.") + .hasSize(1); + } + + @Test + void testPlan_whenACellGetsTheValueItAlreadyHas_makesNoEdit() + { + final List edits = plan(ParsedDataset.of(TEXT), new CellChange(0, "NAME", "Bob")); + + assertThat(edits).as("A change that leaves the text as it is needs no edit.").isEmpty(); + } + + @Test + void testPlan_whenNoChangeIsGiven_makesNoEdit() + { + final List edits = plan(ParsedDataset.of(TEXT)); + + assertThat(edits).as("Nothing to change means nothing to edit.").isEmpty(); + } + + @Test + void testPlan_whenACellBecomesNull_removesItsAttribute() throws Exception + { + final String result = result(ParsedDataset.of(TEXT), new CellChange(0, "EMAIL", null)); + + assertThat(result).as("A NULL cell has no attribute.") + .isEqualTo(TEXT.replace(" EMAIL=\"b@x.org\"", "")); + } + + @Test + void testPlan_whenANullCellGetsAValue_addsTheAttribute() throws Exception + { + final String result = result(ParsedDataset.of(TEXT), new CellChange(1, "EMAIL", "a@x.org")); + + assertThat(result).as("The cell must get an attribute after the others.") + .isEqualTo(TEXT.replace("NAME=\"Alice\"", "NAME=\"Alice\" EMAIL=\"a@x.org\"")); + } + + @Test + void testPlan_whenTheColumnNameDiffersInCase_findsTheColumn() throws Exception + { + final String result = result(ParsedDataset.of(TEXT), new CellChange(0, "name", "Z")); + + assertThat(result).as("Column names are matched ignoring case.") + .isEqualTo(TEXT.replace("NAME=\"Bob\"", "NAME=\"Z\"")); + } + + @Test + void testPlan_whenAValueNeedsEscaping_escapesIt() throws Exception + { + final String result = result(ParsedDataset.of(TEXT), new CellChange(0, "NAME", "a&b edits = cellEdits.plan("USERS", table, List.of(new CellChange(0, "NAME", "é"))); + + assertThat(parsed.apply(edits)).as("The encoder of the context decides what needs a reference.") + .isEqualTo(TEXT.replace("NAME=\"Bob\"", "NAME=\"é\"")); + } + + @Test + void testPlan_whenTheRowDoesNotExist_throwsDatasetEditException() + { + final ParsedDataset parsed = ParsedDataset.of(TEXT); + + assertThatThrownBy(() -> plan(parsed, new CellChange(2, "NAME", "x"))) + .as("A row past the last one cannot be changed.").isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_noSuchRow, 2, "USERS")); + assertThatThrownBy(() -> plan(parsed, new CellChange(-1, "NAME", "x"))) + .as("A negative row cannot be changed.").isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_noSuchRow, -1, "USERS")); + } + + @Test + void testPlan_whenTheColumnDoesNotExist_throwsDatasetEditException() + { + final ParsedDataset parsed = ParsedDataset.of(TEXT); + + assertThatThrownBy(() -> plan(parsed, new CellChange(0, "PHONE", "x"))) + .as("A column that the table does not have cannot be changed.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_noSuchColumn, "PHONE", "USERS")); + } + + @Test + void testPlan_whenTheChangesWouldLeaveARowWithoutValues_throwsDatasetEditException() + { + final ParsedDataset parsed = ParsedDataset.of(TEXT); + + assertThatThrownBy(() -> plan(parsed, new CellChange(1, "ID", null), new CellChange(1, "NAME", null))) + .as("A row must keep at least one value.").isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_rowWouldBeEmpty, 1, "USERS")); + } + + @Test + void testPlan_whenOneCellBecomesNullAndAnotherKeepsItsValue_isAllowed() throws Exception + { + final String result = result(ParsedDataset.of(TEXT), new CellChange(1, "NAME", null)); + + assertThat(result).as("A row that keeps a value may lose another.") + .isEqualTo(TEXT.replace("", "")); + } + + @Test + void testPlan_whenTheFirstRowLosesAColumnThatAnotherRowHas_throwsDatasetEditException() + { + final ParsedDataset parsed = ParsedDataset.of(TEXT); + + assertThatThrownBy(() -> plan(parsed, new CellChange(0, "NAME", null))) + .as("dbUnit takes the columns from the first row, so it would ignore Alice's name.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_firstRowWouldLoseColumn, + new Object[] { "NAME", "USERS" })); + } + + @Test + void testPlan_whenTheChangesTakeAColumnOffEveryRow_isAllowed() throws Exception + { + final String result = result(ParsedDataset.of(TEXT), new CellChange(0, "NAME", null), + new CellChange(1, "NAME", null)); + + assertThat(result).as("Nothing is lost when no row keeps a value for the column.") + .isEqualTo("\n \n \n" + + "\n"); + } + + @Test + void testPlan_whenTheChangesGiveAnotherRowTheValueTheFirstRowLoses_throwsDatasetEditException() + { + final ParsedDataset parsed = ParsedDataset.of(TEXT); + + assertThatThrownBy(() -> plan(parsed, new CellChange(0, "EMAIL", null), + new CellChange(1, "EMAIL", "a@x.org"))) + .as("The second row would keep an email that dbUnit ignores.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_firstRowWouldLoseColumn, + new Object[] { "EMAIL", "USERS" })); + } + + @Test + void testPlan_whenTheDocumentHasADtd_allowsTheFirstRowToLoseAColumn() throws Exception + { + final String text = "\n" + + " \n \n\n"; + final ParsedDataset parsed = ParsedDataset.withDtd(text, + ""); + + assertThat(result(parsed, new CellChange(0, "NAME", null))) + .as("With a DTD, dbUnit takes the columns from the DTD, not from the first row.") + .isEqualTo(text.replace("", "")); + } + + @Test + void testPlan_whenColumnSensingIsOn_allowsTheFirstRowToLoseAColumn() throws Exception + { + final ParsedDataset parsed = ParsedDataset.of(TEXT, new FlatXmlOptions(false, true), "\n"); + + assertThat(result(parsed, new CellChange(0, "NAME", null))) + .as("With column sensing, dbUnit adds the columns of every row.") + .isEqualTo(TEXT.replace("\n" + + " \n\n"; + + assertThat(result(ParsedDataset.of(text), new CellChange(0, "NAME", null))) + .as("The empty element is the first element, so dbUnit has no columns to keep.") + .isEqualTo(text.replace("", "")); + } + + @Test + void testPlan_whenARowHasTwoAttributesForTheColumnThatDifferOnlyInCase_throwsDatasetEditException() + { + final ParsedDataset parsed = + ParsedDataset.of("\n \n\n"); + + assertThatThrownBy(() -> plan(parsed, new CellChange(0, "ID", "5"))) + .as("Changing one of two spellings would leave the other one behind.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_cellHasCaseVariantAttributes, + new Object[] { "ID", 0, "USERS" })); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/CellLocatorTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/CellLocatorTest.java new file mode 100644 index 00000000..77a27469 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/CellLocatorTest.java @@ -0,0 +1,257 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; + +import org.dbunit.eclipse.dataset.core.model.CellAddress; +import org.eclipse.jface.text.IRegion; +import org.eclipse.jface.text.Region; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link CellLocator}: the text range of each kind of address, and the address of each kind of text + * offset, on datasets that are parsed for each test. + */ +class CellLocatorTest +{ + private static final String TWO_ROWS = + ""; + + private static CellLocator locatorFor(final String text) + { + return locatorFor(text, FlatXmlOptions.DBUNIT_DEFAULTS); + } + + private static CellLocator locatorFor(final String text, final FlatXmlOptions options) + { + final ParsedDataset parsed = ParsedDataset.of(text, options, "\n"); + return new CellLocator(parsed.model(), parsed.index(), parsed.options()); + } + + private static Optional region(final int offset, final int length) + { + return Optional.of(new Region(offset, length)); + } + + @Test + void testLocate_whenTheCellHasAValue_returnsTheRangeOfTheAttributeValue() + { + final Optional located = locatorFor(TWO_ROWS).locate(new CellAddress("USERS", 0, 1)); + + assertThat(located).as("The range must cover the raw value between the quotes.") + .isEqualTo(region(TWO_ROWS.indexOf("Bob"), "Bob".length())); + } + + @Test + void testLocate_whenTheRowIsTheSecondOne_returnsTheRangeInThatRow() + { + final Optional located = locatorFor(TWO_ROWS).locate(new CellAddress("USERS", 1, 0)); + + assertThat(located).as("The range must be the ID value of the second row.") + .isEqualTo(region(TWO_ROWS.indexOf("\"2\"") + 1, 1)); + } + + @Test + void testLocate_whenTheCellIsNull_returnsTheRangeOfTheElementName() + { + final String text = ""; + + final Optional located = locatorFor(text).locate(new CellAddress("USERS", 1, 1)); + + assertThat(located).as("A cell without an attribute is located at the name of its element.") + .isEqualTo(region(text.lastIndexOf(""; + + final Optional located = locatorFor(TWO_ROWS).locate(new CellAddress("USERS", 0, -1)); + + assertThat(located).as("A row address must be located at its whole element.") + .isEqualTo(region(TWO_ROWS.indexOf(row), row.length())); + } + + @Test + void testLocate_whenTheTableDoesNotExist_isEmpty() + { + final Optional located = locatorFor(TWO_ROWS).locate(new CellAddress("ORDERS", 0, 0)); + + assertThat(located).as("An unknown table has no location.").isEmpty(); + } + + @Test + void testLocate_whenTheRowIndexIsNegative_isEmpty() + { + final Optional located = locatorFor(TWO_ROWS).locate(new CellAddress("USERS", -1, 0)); + + assertThat(located).as("A negative row index has no location.").isEmpty(); + } + + @Test + void testLocate_whenTheRowIndexIsPastTheLastRow_isEmpty() + { + final Optional located = locatorFor(TWO_ROWS).locate(new CellAddress("USERS", 2, 0)); + + assertThat(located).as("A row past the last one has no location.").isEmpty(); + } + + @Test + void testLocate_whenTheColumnIndexIsPastTheLastColumn_isEmpty() + { + final Optional located = locatorFor(TWO_ROWS).locate(new CellAddress("USERS", 0, 2)); + + assertThat(located).as("A column past the last one has no location.").isEmpty(); + } + + @Test + void testLocate_whenARowSpellsTheColumnNameInAnotherCase_findsTheAttributeIgnoringCase() + { + final String text = ""; + + final Optional located = locatorFor(text).locate(new CellAddress("USERS", 1, 1)); + + assertThat(located).as("The attribute NAME must be found for the column that is spelled name.") + .isEqualTo(region(text.indexOf("\"b\"") + 1, 1)); + } + + @Test + void testCellAt_whenTheOffsetIsInsideAnAttributeValue_returnsThatColumn() + { + final int offset = TWO_ROWS.indexOf("Bob") + 1; + + final Optional address = locatorFor(TWO_ROWS).cellAt(offset); + + assertThat(address).as("An offset inside the NAME value belongs to the NAME cell of the first row.") + .contains(new CellAddress("USERS", 0, 1)); + } + + @Test + void testCellAt_whenTheOffsetIsInsideAnAttributeName_returnsThatColumn() + { + final int offset = TWO_ROWS.indexOf("NAME") + 1; + + final Optional address = locatorFor(TWO_ROWS).cellAt(offset); + + assertThat(address).as("An offset inside an attribute name belongs to its cell.") + .contains(new CellAddress("USERS", 0, 1)); + } + + @Test + void testCellAt_whenTheOffsetIsInTheElementName_returnsTheFirstColumn() + { + final int offset = TWO_ROWS.indexOf(" address = locatorFor(TWO_ROWS).cellAt(offset); + + assertThat(address).as("An offset outside every attribute belongs to the first column.") + .contains(new CellAddress("USERS", 0, 0)); + } + + @Test + void testCellAt_whenTheOffsetIsInTheSecondRow_returnsThatRow() + { + final int offset = TWO_ROWS.indexOf("Alice"); + + final Optional address = locatorFor(TWO_ROWS).cellAt(offset); + + assertThat(address).as("The row index must be the position among the table's rows.") + .contains(new CellAddress("USERS", 1, 1)); + } + + @Test + void testCellAt_whenTheOffsetIsAtTheEndOfARowThatTheNextRowFollowsDirectly_returnsTheNextRow() + { + final int nextRowOffset = TWO_ROWS.lastIndexOf(" address = locatorFor(TWO_ROWS).cellAt(nextRowOffset); + + assertThat(address).as("The end of an element is exclusive, so its successor owns that offset.") + .contains(new CellAddress("USERS", 1, 0)); + } + + @Test + void testCellAt_whenTheOffsetIsInTheRootElementsTag_isEmpty() + { + final Optional address = locatorFor(TWO_ROWS).cellAt(2); + + assertThat(address).as("The root's own tag is not in any row.").isEmpty(); + } + + @Test + void testCellAt_whenTheOffsetIsInTheRootElementsEndTag_isEmpty() + { + final Optional address = locatorFor(TWO_ROWS).cellAt(TWO_ROWS.length() - 2); + + assertThat(address).as("The root's end tag is not in any row.").isEmpty(); + } + + @Test + void testCellAt_whenTheOffsetIsInAnElementWithoutAttributes_isEmpty() + { + final String text = ""; + + final Optional address = locatorFor(text).cellAt(text.indexOf("AUDIT_LOG")); + + assertThat(address).as("An element without attributes is a marker, not a row.").isEmpty(); + } + + @Test + void testCellAt_whenTheModelDoesNotKnowTheAttribute_returnsTheFirstColumn() + { + final ParsedDataset known = ParsedDataset.of(""); + final String text = ""; + final ParsedDataset current = ParsedDataset.of(text); + final CellLocator locator = new CellLocator(known.model(), current.index(), current.options()); + + final Optional address = locator.cellAt(text.indexOf("EXTRA") + 1); + + assertThat(address).as("An attribute without a column in the model falls back to the first column.") + .contains(new CellAddress("USERS", 0, 0)); + } + + @Test + void testCellAt_whenTableNamesAreCaseInsensitive_returnsTheUpperCaseTableKey() + { + final String text = ""; + + final Optional address = locatorFor(text).cellAt(text.indexOf("ID")); + + assertThat(address).as("The default options key tables by their upper-cased name.") + .contains(new CellAddress("USERS", 0, 0)); + } + + @Test + void testCellAt_whenTableNamesAreCaseSensitive_returnsTheTableKeyAsSpelled() + { + final String text = ""; + + final Optional address = + locatorFor(text, new FlatXmlOptions(true, false)).cellAt(text.indexOf("ID")); + + assertThat(address).as("Case-sensitive options key tables by their name as spelled.") + .contains(new CellAddress("users", 0, 0)); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/ColumnEditsTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/ColumnEditsTest.java new file mode 100644 index 00000000..071ff215 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/ColumnEditsTest.java @@ -0,0 +1,310 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; + +import org.dbunit.eclipse.dataset.core.Messages; +import org.dbunit.eclipse.dataset.core.edit.DatasetEditException; +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.eclipse.osgi.util.NLS; +import org.eclipse.text.edits.TextEdit; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link ColumnEdits}: the names that it accepts, the edits that it plans to rename and delete + * columns, and the columns that it refuses to change. + */ +class ColumnEditsTest +{ + private static final String TEXT = "\n" + " \n" + + " \n" + " \n" + "\n"; + + private static DatasetTable usersOf(final ParsedDataset parsed) + { + return parsed.model().findTable("USERS").orElseThrow(); + } + + private static String renamed(final ParsedDataset parsed, final String columnName, + final String newColumnName) throws Exception + { + final DatasetTable table = usersOf(parsed); + final ColumnEdits columnEdits = new ColumnEdits(parsed.editContext()); + final DatasetColumn column = ColumnEdits.requireRename(table, columnName, newColumnName); + return parsed.apply(columnEdits.renameEdits("USERS", table, column, columnName, newColumnName)); + } + + private static List deletionPlan(final ParsedDataset parsed, final String columnName) + { + final DatasetTable table = usersOf(parsed); + final DatasetColumn column = ColumnEdits.requireExistingColumn(table, columnName); + return new ColumnEdits(parsed.editContext()).deleteEdits("USERS", table, column, columnName); + } + + @Test + void testRequireNewColumn_whenTheNameIsFreeAndValid_accepts() + { + final DatasetTable table = usersOf(ParsedDataset.of(TEXT)); + + assertThatCode(() -> ColumnEdits.requireNewColumn(table, "PHONE")).as("A new valid name is fine.") + .doesNotThrowAnyException(); + } + + @Test + void testRequireNewColumn_whenTheNameIsNotAValidXmlName_throwsDatasetEditException() + { + final DatasetTable table = usersOf(ParsedDataset.of(TEXT)); + + for (final String invalid : List.of("1bad", "has space", "")) + { + assertThatThrownBy(() -> ColumnEdits.requireNewColumn(table, invalid)) + .as("'" + invalid + "' cannot be an attribute name.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_invalidColumnName, invalid)); + } + } + + @Test + void testRequireNewColumn_whenAColumnHasTheNameInAnotherCase_throwsDatasetEditException() + { + final DatasetTable table = usersOf(ParsedDataset.of(TEXT)); + + assertThatThrownBy(() -> ColumnEdits.requireNewColumn(table, "email")) + .as("Column names are compared ignoring case.").isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_columnExists, "USERS", "email")); + } + + @Test + void testRequireExistingColumn_whenTheNameDiffersInCase_returnsTheColumn() + { + final DatasetTable table = usersOf(ParsedDataset.of(TEXT)); + + final DatasetColumn column = ColumnEdits.requireExistingColumn(table, "name"); + + assertThat(column).as("The column must be found ignoring case.") + .isEqualTo(new DatasetColumn("NAME", false, true, false)); + } + + @Test + void testRequireExistingColumn_whenThereIsNoSuchColumn_throwsDatasetEditException() + { + final DatasetTable table = usersOf(ParsedDataset.of(TEXT)); + + assertThatThrownBy(() -> ColumnEdits.requireExistingColumn(table, "PHONE")) + .as("A column that the table lacks cannot be found.").isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_noSuchColumn, "PHONE", "USERS")); + } + + @Test + void testRequireRename_whenTheRenameIsValid_returnsTheColumn() + { + final ParsedDataset parsed = ParsedDataset.of(TEXT); + + final DatasetColumn column = ColumnEdits.requireRename(usersOf(parsed), "NAME", "FULLNAME"); + + assertThat(column).as("The column to rename must be returned.") + .isEqualTo(new DatasetColumn("NAME", false, true, false)); + } + + @Test + void testRequireRename_whenOnlyTheLetterCaseChanges_accepts() + { + final DatasetTable table = usersOf(ParsedDataset.of(TEXT)); + + assertThatCode(() -> ColumnEdits.requireRename(table, "NAME", "name")) + .as("A column may take another spelling of its own name.").doesNotThrowAnyException(); + } + + @Test + void testRequireRename_whenTheNewNameBelongsToAnotherColumn_throwsDatasetEditException() + { + final DatasetTable table = usersOf(ParsedDataset.of(TEXT)); + + assertThatThrownBy(() -> ColumnEdits.requireRename(table, "NAME", "email")) + .as("Two columns cannot share a name.").isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_columnExists, "USERS", "email")); + } + + @Test + void testRequireRename_whenTheNewNameIsInvalid_throwsDatasetEditException() + { + final DatasetTable table = usersOf(ParsedDataset.of(TEXT)); + + assertThatThrownBy(() -> ColumnEdits.requireRename(table, "NAME", "1bad")) + .as("The new name must be a valid XML name.").isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_invalidColumnName, "1bad")); + } + + @Test + void testRequireRename_whenTheColumnDoesNotExist_throwsDatasetEditException() + { + final DatasetTable table = usersOf(ParsedDataset.of(TEXT)); + + assertThatThrownBy(() -> ColumnEdits.requireRename(table, "PHONE", "MOBILE")) + .as("Only a column of the table can be renamed.").isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_noSuchColumn, "PHONE", "USERS")); + } + + + @Test + void testRenameEdits_whenRowsHaveTheColumn_rewritesTheirStartTagsOnly() throws Exception + { + final String result = renamed(ParsedDataset.of(TEXT), "NAME", "FULLNAME"); + + assertThat(result).as("The USERS rows must change, and the PETS row, which has a NAME too, must not.") + .isEqualTo(TEXT.replace("NAME=\"Bob\"", "FULLNAME=\"Bob\"").replace("NAME=\"Alice\"", + "FULLNAME=\"Alice\"")); + } + + @Test + void testRenameEdits_whenARowSpellsTheColumnInTwoWays_throwsDatasetEditException() + { + final ParsedDataset parsed = + ParsedDataset.of("\n \n\n"); + + assertThatThrownBy(() -> renamed(parsed, "NAME", "FULLNAME")) + .as("Both attributes would get the new name, and the element would hold it twice.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_renameColumnWithCaseVariants, "NAME", "USERS")); + } + + @Test + void testRenameEdits_whenOnlyAnotherRowSpellsTheColumnInTwoWays_throwsDatasetEditException() + { + final ParsedDataset parsed = ParsedDataset.of("\n \n" + + " \n\n"); + + assertThatThrownBy(() -> renamed(parsed, "NAME", "FULLNAME")) + .as("A row after the first that spells the column in two ways must refuse the rename too.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_renameColumnWithCaseVariants, "NAME", "USERS")); + } + + @Test + void testRenameEdits_whenARowSpellsAnotherColumnInTwoWays_renamesTheColumn() throws Exception + { + final ParsedDataset parsed = ParsedDataset.of( + "\n \n\n"); + + final String result = renamed(parsed, "NAME", "FULLNAME"); + + assertThat(result) + .as("A column that no row spells in two ways can be renamed, whatever other columns do.") + .isEqualTo("\n \n" + + "\n"); + } + + @Test + void testRenameEdits_whenTheColumnIsOnlyDeclared_throwsDatasetEditException() + { + final ParsedDataset parsed = ParsedDataset.withDtd(TEXT, "" + + ""); + + assertThatThrownBy(() -> renamed(parsed, "PHONE", "MOBILE")) + .as("A column that no row has cannot be renamed in the text.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_renameColumnIsDeclaredOnly, "PHONE", "USERS")); + } + + @Test + void testDeleteEdits_whenOneRowHasTheColumn_rewritesOnlyThatStartTag() throws Exception + { + final ParsedDataset parsed = ParsedDataset.of(TEXT); + + final List edits = deletionPlan(parsed, "EMAIL"); + + assertThat(parsed.apply(edits)).as("The attribute must be removed from the row that has it.") + .isEqualTo(TEXT.replace(" EMAIL=\"b@x.org\"", "")); + assertThat(edits).as("A row without the column needs no edit.").hasSize(1); + } + + @Test + void testDeleteEdits_whenEveryRowHasTheColumn_rewritesEachStartTag() throws Exception + { + final ParsedDataset parsed = ParsedDataset.of(TEXT); + + final List edits = deletionPlan(parsed, "name"); + + assertThat(parsed.apply(edits)).as("The column must go from every USERS row, ignoring case.") + .isEqualTo(TEXT.replace(" NAME=\"Bob\"", "").replace(" NAME=\"Alice\"", "")); + } + + @Test + void testDeleteEdits_whenARowSpellsTheColumnInTwoWays_removesBothAttributes() throws Exception + { + final ParsedDataset parsed = ParsedDataset.of( + "\n \n\n"); + + final List edits = deletionPlan(parsed, "NAME"); + + assertThat(parsed.apply(edits)).as("The two spellings are one column, so deleting it removes both.") + .isEqualTo("\n \n\n"); + } + + @Test + void testDeleteEdits_whenARowHasNoOtherColumn_throwsDatasetEditException() + { + final ParsedDataset parsed = ParsedDataset + .of("\n \n \n\n"); + + assertThatThrownBy(() -> deletionPlan(parsed, "NAME")) + .as("Deleting the only column of a row would leave an element without attributes.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_deleteColumnWouldEmptyRow, "NAME", "USERS")); + } + + @Test + void testRequireNewColumnNames_whenTheNamesAreValidAndDistinct_accepts() + { + assertThatCode(() -> ColumnEdits.requireNewColumnNames("USERS", List.of("ID", "NAME"))) + .as("Distinct valid names are fine.").doesNotThrowAnyException(); + } + + @Test + void testRequireNewColumnNames_whenThereAreNoNames_accepts() + { + assertThatCode(() -> ColumnEdits.requireNewColumnNames("USERS", List.of())) + .as("A table may start without columns.").doesNotThrowAnyException(); + } + + @Test + void testRequireNewColumnNames_whenANameIsInvalid_throwsDatasetEditException() + { + assertThatThrownBy(() -> ColumnEdits.requireNewColumnNames("USERS", List.of("ID", "1bad"))) + .as("Every name must be a valid XML name.").isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_invalidColumnName, "1bad")); + } + + @Test + void testRequireNewColumnNames_whenANameRepeatsInAnotherCase_throwsDatasetEditException() + { + assertThatThrownBy(() -> ColumnEdits.requireNewColumnNames("USERS", List.of("ID", "id"))) + .as("Names are compared ignoring case, and the second spelling is reported.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_columnExists, "USERS", "id")); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/ColumnKeysTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/ColumnKeysTest.java new file mode 100644 index 00000000..2c3f9e57 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/ColumnKeysTest.java @@ -0,0 +1,86 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link ColumnKeys}: the position and the spelling of a column by its upper-cased name. + */ +class ColumnKeysTest +{ + private static DatasetColumn column(final String name) + { + return new DatasetColumn(name, false, true, false); + } + + @Test + void testIndexOf_forEachColumn_returnsItsPositionByTheUpperCasedName() + { + final ColumnKeys keys = ColumnKeys.of(List.of(column("Id"), column("name"), column("City"))); + + assertThat(List.of(keys.indexOf("ID"), keys.indexOf("NAME"), keys.indexOf("CITY"))) + .as("Each column must be found by its upper-cased name, at its position.") + .containsExactly(0, 1, 2); + } + + @Test + void testIndexOf_forAnUnknownKey_sortsAfterEveryColumn() + { + final ColumnKeys keys = ColumnKeys.of(List.of(column("ID"))); + + assertThat(keys.indexOf("NOPE")).as("An unknown column must sort last.").isEqualTo(Integer.MAX_VALUE); + } + + @Test + void testNameOf_forEachColumn_returnsItsSpelling() + { + final ColumnKeys keys = ColumnKeys.of(List.of(column("Id"), column("name"))); + + assertThat(List.of(keys.nameOf("ID"), keys.nameOf("NAME"))) + .as("The spelling of the table's column must come back, not the key.") + .containsExactly("Id", "name"); + } + + @Test + void testNameOf_forAnUnknownKey_returnsTheKey() + { + final ColumnKeys keys = ColumnKeys.of(List.of(column("ID"))); + + assertThat(keys.nameOf("CITY")).as("A column that the table lacks keeps the key as its name.") + .isEqualTo("CITY"); + } + + @Test + void testOf_whenTwoColumnsHaveTheSameKey_findsTheLaterOne() + { + final ColumnKeys keys = ColumnKeys.of(List.of(column("id"), column("ID"))); + + assertThat(List.of(keys.indexOf("ID"), keys.nameOf("ID"))) + .as("The later of two columns with one key must be the one that the key finds.") + .containsExactly(1, "ID"); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/DbUnitParity.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/DbUnitParity.java new file mode 100644 index 00000000..3804aeae --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/DbUnitParity.java @@ -0,0 +1,152 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.File; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.dbunit.dataset.Column; +import org.dbunit.dataset.IDataSet; +import org.dbunit.dataset.ITable; +import org.dbunit.dataset.xml.FlatXmlDataSetBuilder; +import org.dbunit.eclipse.dataset.core.TestDatasets; +import org.dbunit.eclipse.dataset.core.dtd.DtdDeclarations; +import org.dbunit.eclipse.dataset.core.dtd.DtdReader; +import org.dbunit.eclipse.dataset.core.model.DatasetModel; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; + +/** + * Compares the model that the editor builds from a dataset text with the dataset that real dbUnit 3.5.2 + * loads from a file, so that the tests of the editor can show that it reads exactly what dbUnit would read. + */ +final class DbUnitParity +{ + private static final Path DATASETS_DIRECTORY = Path.of("src", "test", "resources", "datasets"); + + private DbUnitParity() + { + } + + static void assertParity(final String text, final File file, final boolean sensing, + final boolean everyColumnMustMatch) throws Exception + { + final DatasetModel model = buildModel(text); + final IDataSet dbUnitDataSet = new FlatXmlDataSetBuilder().setColumnSensing(sensing).build(file); + + final String[] dbUnitTableNames = dbUnitDataSet.getTableNames(); + assertThat(model.getTables()).as("Table names and order must match dbUnit.") + .extracting(table -> table.getName().toUpperCase(Locale.ENGLISH)) + .containsExactly(upperCase(dbUnitTableNames)); + + for (int tableIndex = 0; tableIndex < dbUnitTableNames.length; tableIndex++) + { + final ITable dbUnitTable = dbUnitDataSet.getTable(dbUnitTableNames[tableIndex]); + final DatasetTable modelTable = model.getTables().get(tableIndex); + final int rowCount = dbUnitTable.getRowCount(); + assertThat(modelTable.getRows()) + .as("Table '" + modelTable.getName() + "': row count must match dbUnit.") + .hasSize(rowCount); + + final Column[] dbUnitColumns = dbUnitTable.getTableMetaData().getColumns(); + for (final Column column : dbUnitColumns) + { + final String columnName = column.getColumnName(); + final int columnIndex = modelTable.getColumnIndex(columnName); + assertThat(columnIndex) + .as("Table '" + modelTable.getName() + "': column '" + columnName + + "' must exist in the model.") + .isGreaterThanOrEqualTo(0); + for (int row = 0; row < rowCount; row++) + { + final Object expected = dbUnitTable.getValue(row, columnName); + final String actual = modelTable.getEffectiveValue(row, columnIndex); + assertThat(actual) + .as("Table '" + modelTable.getName() + "', row " + row + ", column '" + + columnName + "'.") + .isEqualTo(expected); + } + } + if (everyColumnMustMatch) + { + assertThat(modelTable.getColumns()) + .as("Table '" + modelTable.getName() + "': without a DTD, the model must show " + + "exactly dbUnit's columns, not more.") + .hasSize(dbUnitColumns.length); + } + } + } + + static List columnNames(final ITable table) throws Exception + { + final List names = new ArrayList<>(); + for (final Column column : table.getTableMetaData().getColumns()) + { + names.add(column.getColumnName()); + } + return names; + } + + private static String[] upperCase(final String[] names) + { + final String[] result = new String[names.length]; + for (int i = 0; i < names.length; i++) + { + result[i] = names[i].toUpperCase(Locale.ENGLISH); + } + return result; + } + + private static DatasetModel buildModel(final String text) + { + final FlatXmlParseResult parse = FlatXmlParser.parse(text); + final DtdDeclarations dtd = resolveDtd(parse); + return FlatXmlModelBuilder.build(text, parse, dtd, FlatXmlOptions.DBUNIT_DEFAULTS, Map.of()) + .model(); + } + + private static DtdDeclarations resolveDtd(final FlatXmlParseResult parse) + { + if (parse.doctype() == null) + { + return null; + } + final String internalSubset = parse.doctype().internalSubset(); + final String rootName = parse.doctype().rootName(); + DtdDeclarations declarations = DtdReader.read(internalSubset == null ? "" : internalSubset, rootName); + if (parse.doctype().systemId() != null) + { + declarations = declarations + .merge(DtdReader.read(TestDatasets.read(parse.doctype().systemId()), rootName)); + } + return declarations; + } + + static File datasetsFile(final String name) + { + return DATASETS_DIRECTORY.resolve(name).toFile(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/DoctypeScannerTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/DoctypeScannerTest.java new file mode 100644 index 00000000..ac803f73 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/DoctypeScannerTest.java @@ -0,0 +1,309 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; + +import org.dbunit.eclipse.dataset.core.Messages; +import org.dbunit.eclipse.dataset.core.model.DatasetProblem; +import org.dbunit.eclipse.dataset.core.model.ProblemCode; +import org.dbunit.eclipse.dataset.core.model.ProblemSeverity; +import org.eclipse.osgi.util.NLS; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link DoctypeScanner}: the declaration that it returns, with the offsets of its start, its + * internal subset, and its end, and the blocking problem that each kind of malformed declaration records. + */ +class DoctypeScannerTest +{ + private static FlatXmlDoctype scanAt(final String text, final int start) + { + final ScanProblems problems = new ScanProblems(text); + final XmlLexer lexer = new XmlLexer(text, problems); + lexer.advance(start); + return new DoctypeScanner(lexer, problems).scan(); + } + + private static List problemsAfterFailure(final String text, final int start) + { + final ScanProblems problems = new ScanProblems(text); + final XmlLexer lexer = new XmlLexer(text, problems); + lexer.advance(start); + final DoctypeScanner scanner = new DoctypeScanner(lexer, problems); + assertThatThrownBy(scanner::scan).as("A malformed declaration must stop the scan.") + .isInstanceOf(StopScanException.class); + return problems.toList(); + } + + private static DatasetProblem problemInFirstLine(final String message, final int offset) + { + final int column = offset + 1; + final String located = NLS.bind(Messages.Parser_position, new Object[] { message, 1, column }); + return new DatasetProblem(ProblemCode.NOT_WELL_FORMED, ProblemSeverity.ERROR, located, null, null, -1, + offset, 0); + } + + @Test + void testScan_whenTheDeclarationHasNoIdentifier_returnsOnlyTheRootName() + { + final String text = ""; + + final FlatXmlDoctype doctype = scanAt(text, 0); + + assertThat(doctype).as("Only the root name and the offsets must be set.") + .isEqualTo(new FlatXmlDoctype("dataset", null, null, null, -1, 0, text.length())); + } + + @Test + void testScan_whenTheDeclarationHasASystemIdentifier_returnsIt() + { + final String text = ""; + + final FlatXmlDoctype doctype = scanAt(text, 0); + + assertThat(doctype).as("The system identifier must be returned without its quotes.") + .isEqualTo(new FlatXmlDoctype("dataset", null, "my.dtd", null, -1, 0, text.length())); + } + + @Test + void testScan_whenTheDeclarationHasAPublicIdentifier_returnsBothIdentifiers() + { + final String text = ""; + + final FlatXmlDoctype doctype = scanAt(text, 0); + + assertThat(doctype).as("Both identifiers must be returned, whichever quotes they use.") + .isEqualTo(new FlatXmlDoctype("dataset", "-//pub//id", "my.dtd", null, -1, 0, text.length())); + } + + @Test + void testScan_whenTheDeclarationHasAnInternalSubset_returnsItsTextAndOffset() + { + final String subset = ""; + final String text = ""; + + final FlatXmlDoctype doctype = scanAt(text, 0); + + assertThat(doctype).as("The subset text and the offset of its first character must be returned.") + .isEqualTo(new FlatXmlDoctype("dataset", null, null, subset, text.indexOf('[') + 1, 0, + text.length())); + } + + @Test + void testScan_whenTheDeclarationHasASystemIdentifierAndAnInternalSubset_returnsBoth() + { + final String subset = ""; + final String text = ""; + + final FlatXmlDoctype doctype = scanAt(text, 0); + + assertThat(doctype).as("The identifier and the subset must both be returned.") + .isEqualTo(new FlatXmlDoctype("dataset", null, "my.dtd", subset, text.indexOf('[') + 1, 0, + text.length())); + } + + @Test + void testScan_whenTheSubsetHoldsBracketsAndAnglesInLiteralsCommentsAndInstructions_returnsItWhole() + { + final String subset = "\nc\">" + + ""; + final String text = ""; + + final FlatXmlDoctype doctype = scanAt(text, 0); + + assertThat(doctype.internalSubset()) + .as("A bracket inside a literal, a comment, or an instruction must not end the subset.") + .isEqualTo(subset); + } + + @Test + void testScan_whenTheDeclarationDoesNotStartTheText_recordsOffsetsFromTheStartOfTheText() + { + final String prefix = "\n"; + final String subset = ""; + final String text = prefix + ""; + + final FlatXmlDoctype doctype = scanAt(text, prefix.length()); + + assertThat(doctype).as("All offsets must count from the start of the whole text.") + .isEqualTo(new FlatXmlDoctype("dataset", null, null, subset, + prefix.length() + ""; + + final FlatXmlDoctype doctype = scanAt(text, 0); + + assertThat(doctype).as("Any run of whitespace may separate the parts.") + .isEqualTo(new FlatXmlDoctype("dataset", null, "d.dtd", null, -1, 0, text.length())); + } + + @Test + void testScan_whenTheRootNameIsMissing_recordsABlockingProblemAfterTheKeyword() + { + final List problems = problemsAfterFailure("", 0); + + assertThat(problems).as("The name is expected right after the keyword and its whitespace.") + .containsExactly(problemInFirstLine(Messages.Parser_expectedDoctypeName, 9)); + } + + @Test + void testScan_whenNoWhitespaceSeparatesTheRootName_recordsABlockingProblemAfterTheKeyword() + { + final List problems = problemsAfterFailure("", 0); + + assertThat(problems).as("Whitespace is required between the keyword and the name.") + .containsExactly(problemInFirstLine(Messages.Parser_expectedDoctypeName, 9)); + } + + @Test + void testScan_whenTheRootNameCannotStartAName_recordsABlockingProblemAtIt() + { + final List problems = problemsAfterFailure("", 0); + + assertThat(problems).as("A digit cannot start the root name.") + .containsExactly(problemInFirstLine(Messages.Parser_expectedDoctypeName, 10)); + } + + @Test + void testScan_whenNoWhitespaceFollowsSystem_recordsABlockingProblemAtTheLiteral() + { + final List problems = problemsAfterFailure("", 0); + + assertThat(problems).as("Whitespace is required between SYSTEM and its literal.") + .containsExactly(problemInFirstLine(Messages.Parser_expectedWhitespaceAfterIdKeyword, 24)); + } + + @Test + void testScan_whenNoWhitespaceFollowsPublic_recordsABlockingProblemAtTheLiteral() + { + final List problems = + problemsAfterFailure("", 0); + + assertThat(problems).as("Whitespace is required between PUBLIC and its literal.") + .containsExactly(problemInFirstLine(Messages.Parser_expectedWhitespaceAfterIdKeyword, 24)); + } + + @Test + void testScan_whenNoWhitespaceSeparatesThePublicAndTheSystemLiteral_recordsABlockingProblemAtTheSecond() + { + final String text = ""; + + final List problems = problemsAfterFailure(text, 0); + + assertThat(problems).as("Whitespace is required between the public and the system literal.") + .containsExactly(problemInFirstLine(Messages.Parser_expectedWhitespaceBetweenIds, + text.indexOf("\"d.dtd"))); + } + + @Test + void testScan_whenThePublicIdentifierHasNoSystemLiteral_recordsABlockingProblemAtTheEnd() + { + final String text = ""; + + final List problems = problemsAfterFailure(text, 0); + + assertThat(problems).as("What is missing is the literal, not whitespace before it.") + .containsExactly( + problemInFirstLine(Messages.Parser_expectedQuotedLiteral, text.length() - 1)); + } + + @Test + void testScan_whenTheInternalSubsetFollowsTheLiteralWithoutWhitespace_isAccepted() + { + final String subset = ""; + final String text = ""; + + final FlatXmlDoctype doctype = scanAt(text, 0); + + assertThat(doctype).as("Whitespace before the bracket of the subset is optional.") + .isEqualTo(new FlatXmlDoctype("dataset", null, "d.dtd", subset, text.indexOf('[') + 1, 0, + text.length())); + } + + @Test + void testScan_whenTheSystemIdentifierIsNotQuoted_recordsABlockingProblemAtIt() + { + final List problems = problemsAfterFailure("", 0); + + assertThat(problems).as("The identifier must be a quoted literal.") + .containsExactly(problemInFirstLine(Messages.Parser_expectedQuotedLiteral, 25)); + } + + @Test + void testScan_whenTheSystemIdentifierIsNotClosed_recordsABlockingProblemAtItsOpeningQuote() + { + final List problems = problemsAfterFailure("", 0); + + assertThat(problems).as("An unclosed literal must be reported at its opening quote.") + .containsExactly(problemInFirstLine(Messages.Parser_unclosedLiteral, 25)); + } + + @Test + void testScan_whenTheInternalSubsetIsNotClosed_recordsABlockingProblemAtTheEndOfTheText() + { + final String text = ""; + + final List problems = problemsAfterFailure(text, 0); + + assertThat(problems).as("A subset without its closing bracket must be reported at the end.") + .containsExactly(problemInFirstLine(Messages.Parser_unclosedSubset, text.length())); + } + + @Test + void testScan_whenALiteralInTheSubsetIsNotClosed_recordsABlockingProblemAtItsOpeningQuote() + { + final String text = ""; + + final List problems = problemsAfterFailure(text, 0); + + assertThat(problems).as("An unclosed literal in the subset must be reported at its opening quote.") + .containsExactly( + problemInFirstLine(Messages.Parser_unclosedSubsetLiteral, text.indexOf('"'))); + } + + @Test + void testScan_whenACommentInTheSubsetIsNotClosed_recordsABlockingProblemAtItsStart() + { + final List problems = problemsAfterFailure(" problems = problemsAfterFailure(text, 0); + + assertThat(problems).as("A declaration without its closing bracket must be reported at the end.") + .containsExactly(problemInFirstLine(Messages.Parser_unclosedDoctype, text.length())); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/DtdResolverTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/DtdResolverTest.java new file mode 100644 index 00000000..f8f25b5b --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/DtdResolverTest.java @@ -0,0 +1,318 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.dbunit.eclipse.dataset.core.DatasetCore; +import org.dbunit.eclipse.dataset.core.Messages; +import org.dbunit.eclipse.dataset.core.dtd.DtdDeclarations; +import org.dbunit.eclipse.dataset.core.dtd.DtdSource; +import org.dbunit.eclipse.dataset.core.dtd.DtdTable; +import org.dbunit.eclipse.dataset.core.model.DatasetProblem; +import org.dbunit.eclipse.dataset.core.model.ProblemCode; +import org.dbunit.eclipse.dataset.core.model.ProblemSeverity; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.Status; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link DtdResolver}: what a DOCTYPE resolves to, the caching of external DTDs, the reload of the + * cache, and the warnings for a source that fails. + */ +class DtdResolverTest +{ + private static final String INTERNAL_SUBSET = + ""; + + private static final String EXTERNAL_DTD = ""; + + private static final FlatXmlDoctype EXTERNAL_DOCTYPE = + new FlatXmlDoctype("dataset", null, "my.dtd", INTERNAL_SUBSET, 30, 0, 130); + + private static FlatXmlDoctype internalOnly(final String subset, final int subsetOffset) + { + return new FlatXmlDoctype("dataset", null, null, subset, subsetOffset, 0, subsetOffset + 50); + } + + private static DtdResolver resolverFor(final DtdSource source) + { + return new DtdResolver(source, status -> + { + // These tests do not look at the log. + }); + } + + private static DatasetProblem entityDeclarationInfo(final int offset, final int length) + { + return new DatasetProblem(ProblemCode.UNSUPPORTED_DTD_CONSTRUCT, ProblemSeverity.INFO, + Messages.Dtd_parameterEntityDeclarationsIgnored, null, null, -1, offset, length); + } + + private static List tablesOf(final DtdResolution resolution) + { + final DtdDeclarations declarations = resolution.declarations(); + return declarations.tables(); + } + + @Test + void testResolve_whenThereIsNoDoctype_returnsNoDeclarationsInTheNoneState() + { + final DtdResolution resolution = resolverFor(DtdSource.NONE).resolve(null); + + assertThat(resolution).as("A document without DOCTYPE has no declarations.") + .isEqualTo(new DtdResolution(null, DtdState.NONE)); + } + + @Test + void testResolve_whenTheDoctypeHasAnInternalSubsetOnly_readsItAndIsLoaded() + { + final DtdResolution resolution = + resolverFor(DtdSource.NONE).resolve(internalOnly(INTERNAL_SUBSET, 20)); + + assertThat(resolution.state()).as("A DOCTYPE without a system identifier is complete.") + .isEqualTo(DtdState.LOADED); + assertThat(tablesOf(resolution)).as("The tables must come from the internal subset.") + .containsExactly(new DtdTable("USERS", List.of("ID"))); + } + + @Test + void testResolve_whenTheDoctypeHasNothingToRead_isLoadedWithoutTables() + { + final DtdResolution resolution = resolverFor(DtdSource.NONE).resolve(internalOnly(null, -1)); + + assertThat(resolution.state()).as("A bare DOCTYPE is complete.").isEqualTo(DtdState.LOADED); + assertThat(tablesOf(resolution)).as("A bare DOCTYPE declares no tables.").isEmpty(); + } + + @Test + void testResolve_whenTheInternalSubsetHasProblems_movesThemToTheirPlaceInTheDocument() + { + final String subset = "" + INTERNAL_SUBSET; + + final DtdResolution resolution = resolverFor(DtdSource.NONE).resolve(internalOnly(subset, 25)); + + assertThat(resolution.declarations().getProblems()) + .as("A problem's offset must be relative to the document, not to the subset.") + .containsExactly(entityDeclarationInfo(25, "".length())); + } + + @Test + void testResolve_whenTheDoctypeNamesAnExternalDtd_mergesItAfterTheInternalSubset() + { + final DtdSource source = (publicId, systemId) -> Optional.of(EXTERNAL_DTD); + + final DtdResolution resolution = resolverFor(source).resolve(EXTERNAL_DOCTYPE); + + assertThat(resolution.state()).as("The DTD was loaded.").isEqualTo(DtdState.LOADED); + assertThat(tablesOf(resolution)).as("The external columns must follow the internal ones.") + .containsExactly(new DtdTable("USERS", List.of("ID", "NAME"))); + } + + @Test + void testResolve_whenBothDtdsDeclareDefaults_theInternalSubsetsDefaultWinsAndTheExternalOnesAreKept() + { + final DtdSource source = (publicId, systemId) -> Optional + .of(""); + final FlatXmlDoctype doctype = new FlatXmlDoctype("dataset", null, "my.dtd", + "", 30, 0, 130); + + final DtdResolution resolution = resolverFor(source).resolve(doctype); + + assertThat(tablesOf(resolution)) + .as("The internal subset is read first, so its declaration of STATUS is binding.") + .containsExactly(new DtdTable("USERS", List.of("STATUS", "NAME"), + Map.of("STATUS", "INTERNAL", "NAME", "Anonymous"))); + } + + @Test + void testResolve_whenTheExternalDtdHasProblems_pointsThemAtTheDoctype() + { + final DtdSource source = (publicId, systemId) -> Optional.of(""); + + final DtdResolution resolution = resolverFor(source).resolve(EXTERNAL_DOCTYPE); + + assertThat(resolution.declarations().getProblems()) + .as("A problem in a DTD that is not open must point at the DOCTYPE.") + .containsExactly(entityDeclarationInfo(0, 130)); + } + + @Test + void testResolve_whenTheSourceFindsNoDtd_isNotLoadedButKeepsTheInternalSubset() + { + final DtdResolution resolution = resolverFor(DtdSource.NONE).resolve(EXTERNAL_DOCTYPE); + + assertThat(resolution.state()).as("A DTD that cannot be read leaves the declarations incomplete.") + .isEqualTo(DtdState.NOT_LOADED); + assertThat(tablesOf(resolution)).as("The internal subset must still be read.") + .containsExactly(new DtdTable("USERS", List.of("ID"))); + } + + @Test + void testResolve_whenTheSourceThrows_isNotLoadedAndLogsAWarning() + { + final IllegalStateException failure = new IllegalStateException("broken"); + final List statuses = new ArrayList<>(); + final DtdResolver resolver = new DtdResolver((publicId, systemId) -> + { + throw failure; + }, statuses::add); + + final DtdResolution resolution = resolver.resolve(EXTERNAL_DOCTYPE); + + assertThat(resolution.state()).as("A failing source is treated as one that finds nothing.") + .isEqualTo(DtdState.NOT_LOADED); + assertThat(statuses).as("The failure must be logged once, with its cause.") + .usingRecursiveFieldByFieldElementComparator() + .containsExactly(new Status(IStatus.WARNING, DatasetCore.PLUGIN_ID, + "The DTD source failed to load the DTD \"my.dtd\", so it is treated as not found.", + failure)); + } + + @Test + void testResolve_whenTheSameDtdIsResolvedTwice_loadsItOnce() + { + final AtomicInteger loads = new AtomicInteger(); + final DtdSource source = (publicId, systemId) -> + { + loads.incrementAndGet(); + return Optional.of(EXTERNAL_DTD); + }; + final DtdResolver resolver = resolverFor(source); + + resolver.resolve(EXTERNAL_DOCTYPE); + resolver.resolve(EXTERNAL_DOCTYPE); + + assertThat(loads.get()).as("The second resolution must use the cached text.").isEqualTo(1); + } + + @Test + void testResolve_whenTheFirstLoadFoundNothing_doesNotAskAgain() + { + final AtomicInteger loads = new AtomicInteger(); + final DtdSource source = (publicId, systemId) -> + { + loads.incrementAndGet(); + return Optional.empty(); + }; + final DtdResolver resolver = resolverFor(source); + + resolver.resolve(EXTERNAL_DOCTYPE); + resolver.resolve(EXTERNAL_DOCTYPE); + + assertThat(loads.get()).as("A DTD that was not found is cached as not found until a reload.") + .isEqualTo(1); + } + + @Test + void testReload_whenNothingWasResolved_returnsFalse() + { + final boolean changed = resolverFor(DtdSource.NONE).reload(); + + assertThat(changed).as("An empty cache has nothing that could change.").isFalse(); + } + + @Test + void testReload_whenTheTextIsUnchanged_returnsFalse() + { + final DtdResolver resolver = resolverFor((publicId, systemId) -> Optional.of(EXTERNAL_DTD)); + resolver.resolve(EXTERNAL_DOCTYPE); + + final boolean changed = resolver.reload(); + + assertThat(changed).as("The same text must not count as a change.").isFalse(); + } + + @Test + void testReload_whenTheTextChanged_returnsTrueAndTheNextResolutionSeesTheNewText() + { + final AtomicReference text = new AtomicReference<>(EXTERNAL_DTD); + final DtdResolver resolver = resolverFor((publicId, systemId) -> Optional.of(text.get())); + resolver.resolve(EXTERNAL_DOCTYPE); + text.set(""); + + final boolean changed = resolver.reload(); + + assertThat(changed).as("A different text must count as a change.").isTrue(); + assertThat(tablesOf(resolver.resolve(EXTERNAL_DOCTYPE))) + .as("The cache must hold the reloaded text.") + .containsExactly(new DtdTable("USERS", List.of("ID", "EMAIL"))); + } + + @Test + void testReload_whenTheDtdBecameReadable_returnsTrue() + { + final AtomicReference> text = new AtomicReference<>(Optional.empty()); + final DtdResolver resolver = resolverFor((publicId, systemId) -> text.get()); + resolver.resolve(EXTERNAL_DOCTYPE); + text.set(Optional.of(EXTERNAL_DTD)); + + final boolean changed = resolver.reload(); + + assertThat(changed).as("A DTD that can be read now must count as a change.").isTrue(); + assertThat(resolver.resolve(EXTERNAL_DOCTYPE).state()).as("The DTD is loaded after the reload.") + .isEqualTo(DtdState.LOADED); + } + + @Test + void testReload_whenTheDtdBecameUnreadable_returnsTrue() + { + final AtomicReference> text = new AtomicReference<>(Optional.of(EXTERNAL_DTD)); + final DtdResolver resolver = resolverFor((publicId, systemId) -> text.get()); + resolver.resolve(EXTERNAL_DOCTYPE); + text.set(Optional.empty()); + + final boolean changed = resolver.reload(); + + assertThat(changed).as("A DTD that can no longer be read must count as a change.").isTrue(); + assertThat(resolver.resolve(EXTERNAL_DOCTYPE).state()) + .as("The DTD is not loaded after the reload.").isEqualTo(DtdState.NOT_LOADED); + } + + @Test + void testReload_whenTheSourceThrows_treatsTheDtdAsNotFoundAndLogsAWarning() + { + final AtomicReference failure = new AtomicReference<>(); + final List statuses = new ArrayList<>(); + final DtdResolver resolver = new DtdResolver((publicId, systemId) -> + { + if (failure.get() != null) + { + throw failure.get(); + } + return Optional.of(EXTERNAL_DTD); + }, statuses::add); + resolver.resolve(EXTERNAL_DOCTYPE); + failure.set(new IllegalStateException("broken")); + + final boolean changed = resolver.reload(); + + assertThat(changed).as("A DTD that failed to reload counts as one that became unreadable.").isTrue(); + assertThat(statuses).as("The failure must be logged.").hasSize(1); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/EditContextTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/EditContextTest.java new file mode 100644 index 00000000..49fc7c4a --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/EditContextTest.java @@ -0,0 +1,159 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.Charset; +import java.nio.charset.CharsetEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +import org.dbunit.eclipse.dataset.core.DatasetCore; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.Status; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link EditContext}: what it hands out, where its text comes from, and the encoder that it falls + * back to when the charset supplier gives none or fails. + */ +class EditContextTest +{ + private static final ParsedDataset PARSED = ParsedDataset.of(""); + + private static EditContext contextWith(final Supplier charset, final List statuses) + { + return new EditContext(PARSED.index(), PARSED.layout(), charset, FlatXmlOptions.DBUNIT_DEFAULTS, + statuses::add); + } + + @Test + void testText_whenCalled_returnsTheTextThatTheIndexWasParsedFrom() + { + final EditContext context = contextWith(() -> StandardCharsets.UTF_8, new ArrayList<>()); + + assertThat(context.text()).as("The text must be the one that the offsets of the index belong to, " + + "not a copy of it.").isSameAs(PARSED.index().getText()); + } + + @Test + void testIndex_whenCalled_returnsTheIndexItWasGiven() + { + final EditContext context = contextWith(() -> StandardCharsets.UTF_8, new ArrayList<>()); + + assertThat(context.index()).as("The index must be the one of the parse.").isSameAs(PARSED.index()); + } + + @Test + void testLayout_whenCalled_returnsTheLayoutItWasGiven() + { + final EditContext context = contextWith(() -> StandardCharsets.UTF_8, new ArrayList<>()); + + assertThat(context.layout()).as("The layout must be the one of the parse.") + .isSameAs(PARSED.layout()); + } + + @Test + void testEncoder_whenTheSupplierReturnsACharset_encodesInThatCharset() + { + final List statuses = new ArrayList<>(); + final EditContext context = contextWith(() -> StandardCharsets.ISO_8859_1, statuses); + + final CharsetEncoder encoder = context.encoder(); + + assertThat(encoder.charset()).as("The encoder must use the document's charset.") + .isEqualTo(StandardCharsets.ISO_8859_1); + assertThat(statuses).as("Nothing goes wrong, so nothing is logged.").isEmpty(); + } + + @Test + void testEncoder_whenTheSupplierReturnsNull_fallsBackToUtf8WithoutLogging() + { + final List statuses = new ArrayList<>(); + final EditContext context = contextWith(() -> null, statuses); + + final CharsetEncoder encoder = context.encoder(); + + assertThat(encoder.charset()).as("A missing charset means UTF-8.").isEqualTo(StandardCharsets.UTF_8); + assertThat(statuses).as("A missing charset is not a failure.").isEmpty(); + } + + @Test + void testEncoder_whenTheSupplierThrows_fallsBackToUtf8AndLogsAWarning() + { + final IllegalStateException failure = new IllegalStateException("broken"); + final List statuses = new ArrayList<>(); + final EditContext context = contextWith(() -> + { + throw failure; + }, statuses); + + final CharsetEncoder encoder = context.encoder(); + + assertThat(encoder.charset()).as("A failing supplier means UTF-8.").isEqualTo(StandardCharsets.UTF_8); + assertThat(statuses).as("The failure must be logged once, with its cause.") + .usingRecursiveFieldByFieldElementComparator() + .containsExactly(new Status(IStatus.WARNING, DatasetCore.PLUGIN_ID, + "The charset supplier failed, so the document escapes values for UTF-8.", failure)); + } + + @Test + void testEncoder_whenCalledTwice_returnsANewEncoderEachTime() + { + final EditContext context = contextWith(() -> StandardCharsets.UTF_8, new ArrayList<>()); + + assertThat(context.encoder()).as("An encoder keeps state, so it must not be shared.") + .isNotSameAs(context.encoder()); + } + + @Test + void testFirstElementDefinesColumns_whenThereIsNoDoctypeAndColumnSensingIsOff_isTrue() + { + final EditContext context = ParsedDataset.of("").editContext(); + + assertThat(context.firstElementDefinesColumns()) + .as("dbUnit takes the columns from the first element, so the rule applies.").isTrue(); + } + + @Test + void testFirstElementDefinesColumns_whenColumnSensingIsOn_isFalse() + { + final ParsedDataset parsed = ParsedDataset.of("", + new FlatXmlOptions(false, true), "\n"); + + assertThat(parsed.editContext().firstElementDefinesColumns()) + .as("With column sensing, dbUnit adds the columns of every element.").isFalse(); + } + + @Test + void testFirstElementDefinesColumns_whenTheDocumentHasADoctype_isFalse() + { + final ParsedDataset parsed = ParsedDataset.withDtd( + "", + ""); + + assertThat(parsed.editContext().firstElementDefinesColumns()) + .as("With a DTD, dbUnit takes the columns from the DTD.").isFalse(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlContentDescriberTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlContentDescriberTest.java new file mode 100644 index 00000000..12c008dd --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlContentDescriberTest.java @@ -0,0 +1,166 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.core.runtime.content.IContentDescriber.INDETERMINATE; +import static org.eclipse.core.runtime.content.IContentDescriber.INVALID; +import static org.eclipse.core.runtime.content.IContentDescriber.VALID; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link FlatXmlContentDescriber} against the content-recognition rules of the specification. + */ +class FlatXmlContentDescriberTest +{ + @Test + void testDescribe_whenContentIsAFlatDataset_returnsValid() throws IOException + { + assertThat(describe("")) + .as("A flat dataset must be recognized as valid.").isEqualTo(VALID); + } + + @Test + void testDescribe_whenDoctypeReferencesADtdFileThatDoesNotExist_returnsValid() throws IOException + { + final String content = "" + + ""; + assertThat(describe(content)) + .as("An external DTD must never be loaded, so a missing file must not matter.") + .isEqualTo(VALID); + } + + @Test + void testDescribe_whenInternalSubsetDeclaresAnExternalEntity_returnsValidWithoutFileAccess() + throws IOException + { + final String content = "\n" + "]>\n" + + ""; + assertThat(describe(content)) + .as("An external entity must never be read, so a missing file must not matter.") + .isEqualTo(VALID); + } + + @Test + void testDescribe_whenContentIsTheFullXmlDataSetFormat_returnsInvalid() throws IOException + { + final String content = "ID" + + "1
"; + assertThat(describe(content)).as("The full XmlDataSet format must not be recognized.") + .isEqualTo(INVALID); + } + + @Test + void testDescribe_whenAFlatTableIsNamedTableAndHasANameColumn_returnsValid() throws IOException + { + final String content = "
"; + assertThat(describe(content)) + .as("A flat table named table that has more columns than name is no full XML table.") + .isEqualTo(VALID); + } + + @Test + void testDescribe_whenAFlatTableIsNamedTableAndHasNoNameColumn_returnsValid() throws IOException + { + assertThat(describe("
")) + .as("A flat table named table without a name attribute is not the full XML format.") + .isEqualTo(VALID); + } + + @Test + void testDescribe_whenTheFirstTableElementHasOnlyANameAttribute_returnsInvalid() throws IOException + { + final String content = "
"; + assertThat(describe(content)) + .as("A table element with only a name attribute is read as the full XML format.") + .isEqualTo(INVALID); + } + + @Test + void testDescribe_whenRootIsNotDataset_returnsInvalid() throws IOException + { + assertThat(describe("")).as("A root other than dataset must be invalid.") + .isEqualTo(INVALID); + } + + @Test + void testDescribe_whenDatasetIsEmpty_returnsValid() throws IOException + { + assertThat(describe("")).as("A dataset with no tables must be valid.").isEqualTo(VALID); + } + + @Test + void testDescribe_whenContentIsMalformedAfterTheRoot_returnsValid() throws IOException + { + assertThat(describe(""; + final FlatXmlContentDescriber describer = new FlatXmlContentDescriber(); + + final int fromReader = describer.describe(new StringReader(content), null); + + assertThat(fromReader).as("The Reader variant must match the InputStream variant.") + .isEqualTo(describe(content)); + } + + private static int describe(final String content) throws IOException + { + final FlatXmlContentDescriber describer = new FlatXmlContentDescriber(); + final InputStream stream = new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); + return describer.describe(stream, null); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocumentTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocumentTest.java new file mode 100644 index 00000000..24fcae8f --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocumentTest.java @@ -0,0 +1,3940 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; +import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.stream.IntStream; + +import org.dbunit.dataset.Column; +import org.dbunit.dataset.DataSetException; +import org.dbunit.dataset.IDataSet; +import org.dbunit.dataset.ITable; +import org.dbunit.dataset.xml.FlatXmlDataSetBuilder; +import org.dbunit.eclipse.dataset.core.TestDatasets; +import org.dbunit.eclipse.dataset.core.dtd.DtdSource; +import org.dbunit.eclipse.dataset.core.edit.CellChange; +import org.dbunit.eclipse.dataset.core.edit.ChangeOrigin; +import org.dbunit.eclipse.dataset.core.edit.DatasetEditException; +import org.dbunit.eclipse.dataset.core.edit.TableChanges; +import org.dbunit.eclipse.dataset.core.model.CellAddress; +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.dbunit.eclipse.dataset.core.model.DatasetModel; +import org.dbunit.eclipse.dataset.core.model.DatasetProblem; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.dbunit.eclipse.dataset.core.model.ProblemCode; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.Status; +import org.eclipse.jface.text.Document; +import org.eclipse.jface.text.DocumentEvent; +import org.eclipse.jface.text.DocumentRewriteSessionEvent; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.IDocumentExtension4; +import org.eclipse.jface.text.IDocumentListener; +import org.eclipse.jface.text.IRegion; +import org.eclipse.text.undo.DocumentUndoManagerRegistry; +import org.eclipse.text.undo.IDocumentUndoManager; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Tests {@link FlatXmlDatasetDocument} against the staleness, refresh, and navigation rules of the Edit + * Engine specification, using a plain {@link Document} (no workbench needed). It also checks the problems + * that a refresh reports and the text that an edit writes against real dbUnit 3.5.2, which must still load + * what the editor writes and fail where the model reports a problem. + */ +class FlatXmlDatasetDocumentTest +{ + private static final String CASE_VARIANT_TABLES_DATASET = "\n" + + "\n\n" + + "\n\n]>\n\n"; + + @Test + void testDocumentChanged_whenTheDocumentChanges_marksTheModelStaleWithoutRefreshing() + throws Exception + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + document.replace(0, 0, ""); + + assertThat(datasetDocument.isStale()).as("Any document change must mark the model stale.") + .isTrue(); + assertThat(datasetDocument.getModel().getTables()).as("getModel must never reparse.").hasSize(1); + } + + @Test + void testRefresh_whenStale_notifiesListenersOnceWithDocumentOrigin() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + final AtomicInteger notifications = new AtomicInteger(); + final List origins = new ArrayList<>(); + datasetDocument.addModelListener(event -> + { + notifications.incrementAndGet(); + origins.add(event.origin()); + }); + + datasetDocument.refresh(); + + assertThat(notifications.get()).as("refresh() on a stale model must notify exactly once.") + .isEqualTo(1); + assertThat(origins).as("A plain refresh must carry ChangeOrigin.DOCUMENT.") + .containsExactly(ChangeOrigin.DOCUMENT); + } + + @Test + void testRefresh_whenNotStale_doesNotNotify() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + final AtomicInteger notifications = new AtomicInteger(); + datasetDocument.addModelListener(event -> notifications.incrementAndGet()); + + datasetDocument.refresh(); + + assertThat(notifications.get()).as("refresh() must do nothing when the model is not stale.") + .isEqualTo(0); + } + + @Test + void testRequireTable_whenTheModelHasTheTable_returnsIt() + { + final FlatXmlDatasetDocument datasetDocument = + create(new Document("")); + datasetDocument.refresh(); + + final DatasetTable table = datasetDocument.requireTable("ORDERS"); + + assertThat(table).as("The table with the key must be the one of the model.") + .isSameAs(datasetDocument.getModel().getTables().get(1)); + } + + @Test + void testRequireTable_whenNoTableHasTheKey_throwsAnEditExceptionNamingTheKey() + { + final FlatXmlDatasetDocument datasetDocument = + create(new Document("")); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.requireTable("GONE")).as("A missing table must be refused.") + .isInstanceOf(DatasetEditException.class).hasMessage("There is no table 'GONE'."); + } + + @Test + void testRequireTable_whenTheTextChangedSinceTheLastRefresh_answersFromTheModelOfTheLastRefresh() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + document.set(""); + + final DatasetTable table = datasetDocument.requireTable("USERS"); + + assertThat(table).as("The table of the last refresh must be returned, with no new parse.") + .isSameAs(datasetDocument.getModel().getTables().get(0)); + assertThat(datasetDocument.isStale()).as("The lookup must not refresh the model.").isTrue(); + } + + @Test + void testIsBlank_forTextThatIsEmptyOrOnlyWhitespace_isTrue() + { + assertThat(List.of(create(new Document("")).isBlank(), create(new Document(" \t\r\n ")).isBlank(), + create(new Document("\n".repeat(10_000))).isBlank())) + .as("An empty text and a text of whitespace only are blank.") + .containsExactly(true, true, true); + } + + @Test + void testIsBlank_forTextOfCharactersThatOnlyJavaCallsWhitespace_isFalse() + { + final String ideographicSpace = String.valueOf((char) 0x3000); + final String formFeed = String.valueOf((char) 0xC); + final String emSpace = String.valueOf((char) 0x2003); + + assertThat(List.of(create(new Document(ideographicSpace)).isBlank(), + create(new Document(" " + formFeed)).isBlank(), create(new Document(emSpace)).isBlank())) + .as("The XML parser sees an ideographic space, a form feed, and an em space as text, not " + + "as whitespace, so a document of them has content, though String.isBlank " + + "disagrees.") + .containsOnly(false); + } + + @Test + void testRefresh_whenTheTextHoldsOnlyAnIdeographicSpace_buildsAModelThatIsNotBlank() + { + final FlatXmlDatasetDocument datasetDocument = + create(new Document(String.valueOf((char) 0x3000))); + + datasetDocument.refresh(); + + assertThat(datasetDocument.getModel().isBlank()) + .as("The model must agree with the text about what is blank.").isFalse(); + } + + @Test + void testIsBlank_forTextWithAnyOtherCharacter_isFalse() + { + final String nonBreakingSpace = String.valueOf((char) 0xA0); + + assertThat(List.of(create(new Document("x")).isBlank(), + create(new Document(" ")).isBlank(), + create(new Document(" ".repeat(10_000) + "x")).isBlank(), + create(new Document(nonBreakingSpace)).isBlank())) + .as("Any character that is not whitespace makes the text not blank, a no-break space too, " + + "as for String.isBlank.") + .containsExactly(false, false, false, false); + } + + @Test + void testIsBlank_whenTheTextIsNotBlank_doesNotCopyTheText() + { + final CountingDocument document = new CountingDocument(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + final int copiesBefore = document.copies; + + final boolean blank = datasetDocument.isBlank(); + + assertThat(blank).as("The text is not blank.").isFalse(); + assertThat(document.copies - copiesBefore) + .as("The page asks several times for each change of the text, so the answer must not copy " + + "the whole text each time.") + .isZero(); + } + + @Test + void testIsBlank_afterTheTextChanges_answersForTheTextAsItIsNow() + throws Exception + { + final IDocument document = new Document(" "); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + document.replace(1, 0, "x"); + + assertThat(datasetDocument.isBlank()) + .as("Text that arrived before the model was refreshed must count at once, because " + + "creating the empty dataset must not overwrite it.") + .isFalse(); + } + + @Test + void testRefresh_whenTheTextIsEmpty_buildsABlankModelWithoutProblems() + { + final FlatXmlDatasetDocument datasetDocument = create(new Document("")); + + datasetDocument.refresh(); + + final DatasetModel model = datasetDocument.getModel(); + assertThat(List.of(model.isBlank(), model.getProblems().isEmpty(), model.isEditable())) + .as("An empty text is blank, which is no problem to list, and nothing to edit until an " + + "empty dataset is created.") + .containsExactly(true, true, false); + } + + @Test + void testRefresh_whenTheTextIsOnlyWhitespace_buildsABlankModelWithoutProblems() + { + final FlatXmlDatasetDocument datasetDocument = create(new Document(" \t\r\n ")); + + datasetDocument.refresh(); + + final DatasetModel model = datasetDocument.getModel(); + assertThat(List.of(model.isBlank(), model.getProblems().isEmpty())) + .as("A text of whitespace only is as blank as an empty one.").containsExactly(true, true); + } + + @Test + void testRefresh_whenTheTextHasContentThatIsNotADataset_buildsAModelThatIsNotBlank() + { + final FlatXmlDatasetDocument datasetDocument = create(new Document("")); + + datasetDocument.refresh(); + + final DatasetModel model = datasetDocument.getModel(); + assertThat(List.of(model.isBlank(), model.getProblems().isEmpty())) + .as("A text with content is not blank, and the root that is wrong is a problem.") + .containsExactly(false, false); + } + + @Test + void testRefresh_whenABlankTextGetsContentAndLosesItAgain_followsTheText() throws Exception + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.createEmptyDataset(); + final boolean blankWithContent = datasetDocument.getModel().isBlank(); + final boolean editableWithContent = datasetDocument.getModel().isEditable(); + document.set(" "); + datasetDocument.refresh(); + + assertThat(List.of(blankWithContent, editableWithContent, datasetDocument.getModel().isBlank())) + .as("The model must not be blank once the empty dataset is created, and must be blank " + + "again when the text is.") + .containsExactly(false, true, true); + } + + @Test + void testCreateEmptyDataset_whenDocumentIsBlank_replacesItAndUndoRestoresTheBlankText() + throws Exception + { + final IDocument document = new Document(" "); + DocumentUndoManagerRegistry.connect(document); + final IDocumentUndoManager undoManager = + DocumentUndoManagerRegistry.getDocumentUndoManager(document); + undoManager.connect(this); + try + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.createEmptyDataset(); + + assertThat(document.get()).as("createEmptyDataset must replace the blank text.") + .contains("").contains(""); + assertThat(undoManager.undoable()).as("The replacement must be one undoable change.") + .isTrue(); + + undoManager.undo(); + + assertThat(document.get()).as("Undo must restore the original blank text.").isEqualTo(" "); + } + finally + { + undoManager.disconnect(this); + DocumentUndoManagerRegistry.disconnect(document); + } + } + + @Test + void testCreateEmptyDataset_whenDocumentDelimiterIsCrLf_writesTheEmptyDatasetTextWithCrLf() + { + final Document document = new Document(""); + document.setInitialLineDelimiter("\r\n"); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.createEmptyDataset(); + + assertThat(document.get()).as("createEmptyDataset must end every line with the document's delimiter.") + .isEqualTo("\r\n\r\n\r\n"); + } + + @Test + void testEmptyDatasetText_withLf_returnsTheXmlDeclarationAndAnEmptyDatasetRoot() + { + final String text = FlatXmlDatasetDocument.emptyDatasetText("\n"); + + assertThat(text).as("The empty dataset must be the UTF-8 declaration and an empty dataset root.") + .isEqualTo("\n\n\n"); + } + + @Test + void testCreateEmptyDataset_whenDocumentIsNotBlank_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(datasetDocument::createEmptyDataset) + .as("createEmptyDataset must reject a non-blank document.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(""); + } + + @Test + void testSetOptions_whenCalled_refreshesTheModel() + { + final IDocument document = + new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + assertThat(datasetDocument.getModel().getTables()).as("Case-insensitive by default: one table.") + .hasSize(1); + + datasetDocument.setOptions(new FlatXmlOptions(true, false)); + + assertThat(datasetDocument.getModel().getTables()) + .as("setOptions must refresh immediately with the new options.").hasSize(2); + } + + @Test + void testSetOptions_whenTableNamesBecomeCaseSensitiveAndThenInsensitiveAgain_keepsThePendingColumns() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "EXTRA"); + final DatasetColumn pendingExtra = new DatasetColumn("EXTRA", false, false, true); + + datasetDocument.setOptions(new FlatXmlOptions(true, false)); + final List whenCaseSensitive = + datasetDocument.getModel().findTable("users").orElseThrow().getColumns(); + datasetDocument.setOptions(FlatXmlOptions.DBUNIT_DEFAULTS); + final List whenCaseInsensitiveAgain = + datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns(); + + assertThat(List.of(whenCaseSensitive.contains(pendingExtra), + whenCaseInsensitiveAgain.contains(pendingExtra))) + .as("The pending column must stay with its table when the key of the table changes, " + + "and when it changes back.") + .containsExactly(true, true); + } + + @Test + void testSetOptions_whenTwoSpellingsOfATableBecomeOne_mergesTheirPendingColumns() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.setOptions(new FlatXmlOptions(true, false)); + datasetDocument.addColumn("users", "FIRST"); + datasetDocument.addColumn("USERS", "SECOND"); + + datasetDocument.setOptions(FlatXmlOptions.DBUNIT_DEFAULTS); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getColumns()).extracting(DatasetColumn::name, DatasetColumn::pending) + .as("Both spellings are one table now, which keeps the pending columns of both.") + .containsExactly(tuple("ID", false), tuple("FIRST", true), tuple("SECOND", true)); + } + + @Test + void testReloadDtd_whenTheCachedDtdTextChanges_refreshes() + { + final IDocument document = new Document( + ""); + final MutableDtdSource dtdSource = new MutableDtdSource( + ""); + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(document, dtdSource, + FlatXmlOptions.DBUNIT_DEFAULTS, () -> StandardCharsets.UTF_8); + datasetDocument.refresh(); + assertThat(datasetDocument.getModel().getTables().get(0).getColumns()).as("ID must be declared.") + .hasSize(1); + + dtdSource.setText("" + + ""); + datasetDocument.reloadDtd(); + + assertThat(datasetDocument.getModel().getTables().get(0).getColumns()) + .as("reloadDtd must refresh once the cached DTD text changes.").hasSize(2); + } + + @Test + void testReloadDtd_whenTheCachedDtdTextIsUnchanged_doesNotNotify() + { + final IDocument document = new Document( + ""); + final String dtdText = + ""; + final MutableDtdSource dtdSource = new MutableDtdSource(dtdText); + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(document, dtdSource, + FlatXmlOptions.DBUNIT_DEFAULTS, () -> StandardCharsets.UTF_8); + datasetDocument.refresh(); + final AtomicInteger notifications = new AtomicInteger(); + datasetDocument.addModelListener(event -> notifications.incrementAndGet()); + + datasetDocument.reloadDtd(); + + assertThat(notifications.get()) + .as("reloadDtd must not refresh or notify when the DTD text is unchanged.") + .isEqualTo(0); + assertThat(datasetDocument.isStale()).as("The model must not be marked stale either.").isFalse(); + } + + @Test + void testRefresh_whenAnExternalDtdDeclaresTheTable_marksTheTableDeclaredInTheExternalDtd() + { + final IDocument document = new Document( + ""); + final MutableDtdSource dtdSource = new MutableDtdSource( + ""); + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(document, dtdSource, + FlatXmlOptions.DBUNIT_DEFAULTS, () -> StandardCharsets.UTF_8); + + datasetDocument.refresh(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().isDeclaredInExternalDtd()) + .as("The editor cannot rename the table in the DTD file, so the model must say that the " + + "file declares it.") + .isTrue(); + } + + @Test + void testRefresh_whenOnlyTheInternalSubsetDeclaresTheTable_doesNotMarkItDeclaredInTheExternalDtd() + { + final IDocument document = new Document("\n" + + "\n\n]>\n" + + ""); + final FlatXmlDatasetDocument datasetDocument = create(document); + + datasetDocument.refresh(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().isDeclaredInExternalDtd()) + .as("The editor renames a table in the internal subset itself, so no DTD file is " + + "involved.") + .isFalse(); + } + + @Test + void testRefresh_whenTheDtdSourceThrows_yieldsADtdNotLoadedProblem() + { + final IDocument document = new Document( + ""); + final DtdSource throwingSource = (publicId, systemId) -> + { + throw new IllegalArgumentException("URI is not hierarchical"); + }; + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(document, throwingSource, + FlatXmlOptions.DBUNIT_DEFAULTS, () -> StandardCharsets.UTF_8); + + datasetDocument.refresh(); + + assertThat(datasetDocument.getModel().getProblems()).extracting(DatasetProblem::code) + .as("A DtdSource that throws must yield the same DTD_NOT_LOADED warning as one that finds " + + "nothing.") + .contains(ProblemCode.DTD_NOT_LOADED); + } + + @Test + void testRefresh_whenTheDtdSourceThrows_logsAWarningThatNamesTheDtd() + { + final IDocument document = new Document( + ""); + final IllegalArgumentException failure = new IllegalArgumentException("URI is not hierarchical"); + final DtdSource throwingSource = (publicId, systemId) -> + { + throw failure; + }; + final List logged = new ArrayList<>(); + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(document, throwingSource, + FlatXmlOptions.DBUNIT_DEFAULTS, () -> StandardCharsets.UTF_8, logged::add); + + datasetDocument.refresh(); + + assertThat(logged).as("A DtdSource that throws must be logged once, with its failure.") + .usingRecursiveComparison() + .isEqualTo(List.of(new Status(IStatus.WARNING, "org.dbunit.eclipse.dataset.core", + "The DTD source failed to load the DTD \"my.dtd\", so it is treated as not found.", + failure))); + } + + @Test + void testReloadDtd_whenTheDtdSourceThrows_treatsTheDtdAsNotLoadedAndLogsAWarning() + { + final IDocument document = new Document( + ""); + final AtomicBoolean failing = new AtomicBoolean(); + final IllegalStateException failure = new IllegalStateException("The DTD source is broken."); + final DtdSource source = (publicId, systemId) -> + { + if (failing.get()) + { + throw failure; + } + return Optional.of( + ""); + }; + final List logged = new ArrayList<>(); + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(document, source, + FlatXmlOptions.DBUNIT_DEFAULTS, () -> StandardCharsets.UTF_8, logged::add); + datasetDocument.refresh(); + failing.set(true); + + datasetDocument.reloadDtd(); + + assertThat(datasetDocument.getModel().getProblems()).extracting(DatasetProblem::code) + .as("A DtdSource that throws on reload must leave the DTD not loaded, like one that finds " + + "nothing.") + .contains(ProblemCode.DTD_NOT_LOADED); + assertThat(logged).as("The failure while reloading must be logged once.") + .usingRecursiveComparison() + .isEqualTo(List.of(new Status(IStatus.WARNING, "org.dbunit.eclipse.dataset.core", + "The DTD source failed to load the DTD \"my.dtd\", so it is treated as not found.", + failure))); + } + + @Test + void testRefresh_whenInternalSubsetHasAParameterEntity_pointsTheProblemAtItsOwnOffset() + { + final String text = "\n" + + "\n]>\n" + + ""; + final IDocument document = new Document(text); + final FlatXmlDatasetDocument datasetDocument = create(document); + + datasetDocument.refresh(); + + final DatasetProblem problem = datasetDocument.getModel().getProblems().stream() + .filter(candidate -> candidate.code() == ProblemCode.UNSUPPORTED_DTD_CONSTRUCT).findFirst() + .orElseThrow(); + assertThat(problem.offset()) + .as("An internal subset's problem must point at its own text in the document.") + .isEqualTo(text.indexOf(""; + final IDocument document = new Document(text); + final MutableDtdSource dtdSource = new MutableDtdSource( + "" + + ""); + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(document, dtdSource, + FlatXmlOptions.DBUNIT_DEFAULTS, () -> StandardCharsets.UTF_8); + + datasetDocument.refresh(); + + final DatasetProblem problem = datasetDocument.getModel().getProblems().stream() + .filter(candidate -> candidate.code() == ProblemCode.UNSUPPORTED_DTD_CONSTRUCT).findFirst() + .orElseThrow(); + final int doctypeOffset = text.indexOf("', doctypeOffset) + 1 - doctypeOffset; + assertThat(problem.offset()) + .as("An external DTD's problem must point at the DOCTYPE, since the DTD file is not open.") + .isEqualTo(doctypeOffset); + assertThat(problem.length()).as("Its length must span the whole DOCTYPE declaration.") + .isEqualTo(doctypeLength); + } + + @Test + void testLocateAndCellAt_whenGivenEveryNonNullCellOfEditorSample_roundTrip() + throws AttributeValueException + { + final IDocument document = new Document(TestDatasets.read("editor-sample.xml")); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + final DatasetModel model = datasetDocument.getModel(); + + for (final DatasetTable table : model.getTables()) + { + for (int rowIndex = 0; rowIndex < table.getRows().size(); rowIndex++) + { + for (int columnIndex = 0; columnIndex < table.getColumns().size(); columnIndex++) + { + assertCellRoundTrips(document, datasetDocument, table, rowIndex, columnIndex); + } + } + } + } + + private void assertCellRoundTrips(final IDocument document, + final FlatXmlDatasetDocument datasetDocument, final DatasetTable table, final int rowIndex, + final int columnIndex) throws AttributeValueException + { + final String value = table.getRows().get(rowIndex).getValue(columnIndex); + if (value == null) + { + return; + } + final CellAddress address = new CellAddress(table.getKey(), rowIndex, columnIndex); + final Optional region = datasetDocument.locate(address); + assertThat(region).as("locate must find every non-null cell.").isPresent(); + final String rawLocated = document.get().substring(region.get().getOffset(), + region.get().getOffset() + region.get().getLength()); + assertThat(AttributeValueCodec.decode(rawLocated)) + .as("The located raw text, decoded, must equal the model's value.").isEqualTo(value); + final Optional roundTripped = datasetDocument.cellAt(region.get().getOffset()); + assertThat(roundTripped).as("cellAt must find the same cell locate pointed at.").contains(address); + } + + @Test + void testLocate_whenTheRowSpellsTheColumnWithAnotherNameOfTheSameKey_returnsTheValueRange() + { + final String text = "" + + ""; + final IDocument document = new Document(text); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + final Optional region = datasetDocument.locate(new CellAddress("USERS", 1, 1)); + + assertThat(region).as("A cell whose row spells the column with another key-equal name must be " + + "located.").isPresent(); + final String located = text.substring(region.get().getOffset(), + region.get().getOffset() + region.get().getLength()); + assertThat(located) + .as("The attribute STRASSE has the same key as the column, which the first row spells " + + "differently, so locate must return its value, not the element name.") + .isEqualTo("Side"); + } + + @Test + void testCellAt_whenTheAttributeSharesTheColumnKeyYetIsNotEqualIgnoringCase_returnsThatColumn() + { + final String text = "" + + ""; + final IDocument document = new Document(text); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + final Optional address = datasetDocument.cellAt(text.indexOf("STRASSE")); + + assertThat(address) + .as("An offset in the attribute STRASSE must map to the column with the same key, not to " + + "the first column.") + .contains(new CellAddress("USERS", 1, 1)); + } + + @Test + void testLocateAndCellAt_whenTheTextChangedAndNothingRefreshed_answerForTheCurrentText() throws Exception + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + document.replace(0, 0, ""); + final int idOfSecondRow = document.get().indexOf("\"2\"") + 1; + + final Optional region = datasetDocument.locate(new CellAddress("USERS", 1, 0)); + final Optional address = datasetDocument.cellAt(idOfSecondRow); + + assertThat(region.map(found -> document.get().substring(found.getOffset(), + found.getOffset() + found.getLength()))).as("The cell must be found where its text is now.") + .contains("2"); + assertThat(address).as("The offset must be mapped to the cell that has it now.") + .contains(new CellAddress("USERS", 1, 0)); + } + + @Test + void testLocate_whenCellIsNull_returnsTheElementNameRange() + { + final IDocument document = new Document(TestDatasets.read("editor-sample.xml")); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + final DatasetTable users = datasetDocument.getModel().findTable("USERS").orElseThrow(); + final int emailColumn = users.getColumnIndex("EMAIL"); + int nullRow = -1; + for (int i = 0; i < users.getRows().size(); i++) + { + if (users.getRows().get(i).getValue(emailColumn) == null) + { + nullRow = i; + break; + } + } + assertThat(nullRow).as("editor-sample.xml must have a USERS row with no EMAIL.") + .isGreaterThanOrEqualTo(0); + + final Optional region = + datasetDocument.locate(new CellAddress(users.getKey(), nullRow, emailColumn)); + + assertThat(region).as("A NULL cell must still be located.").isPresent(); + final String located = document.get().substring(region.get().getOffset(), + region.get().getOffset() + region.get().getLength()); + assertThat(located).as("For a NULL cell, locate must return the element name.").isEqualTo("USERS"); + } + + @Test + void testSetCells_whenChangingDoubleAndSingleQuotedValues_keepsEachAttributesQuoteStyle() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.setCells("USERS", + List.of(new CellChange(0, "ID", "2"), new CellChange(0, "NAME", "Rob"))); + + assertThat(document.get()).as("Each attribute must keep its own quote style.") + .isEqualTo(""); + } + + @Test + void testSetCells_whenSettingNull_removesTheAttributeAndItsLeadingWhitespace() + { + final IDocument document = + new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", null))); + + assertThat(document.get()).as("Setting NULL must remove the attribute and its leading whitespace.") + .isEqualTo(""); + } + + @Test + void testSetCells_whenSettingAValueOnANullCellThatIsTheFirstColumn_insertsItFirst() + { + final IDocument document = new Document("" + + ""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.setCells("USERS", List.of(new CellChange(1, "ID", "2"))); + + assertThat(document.get()).as("A value for the first column must be inserted first.") + .isEqualTo("" + + ""); + } + + @Test + void testSetCells_whenSettingAValueOnANullCellThatIsAMiddleColumn_insertsItInTheMiddle() + { + final IDocument document = new Document("" + + ""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.setCells("USERS", List.of(new CellChange(1, "NAME", "Bob"))); + + assertThat(document.get()).as("A value for a middle column must be inserted between its neighbors.") + .isEqualTo("" + + ""); + } + + @Test + void testSetCells_whenSettingAValueOnANullCellThatIsTheLastColumn_insertsItLast() + { + final IDocument document = new Document("" + + ""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.setCells("USERS", List.of(new CellChange(1, "EMAIL", "b@x.org"))); + + assertThat(document.get()).as("A value for the last column must be inserted last.") + .isEqualTo("" + + ""); + } + + @Test + void testSetCells_whenChangingSeveralCellsOfOneElement_isOneUndoStep() throws Exception + { + final IDocument document = + new Document(""); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.setCells("USERS", + List.of(new CellChange(0, "NAME", "Bob"), new CellChange(0, "EMAIL", "b@x.org"))); + + assertThat(document.get()).as("Both changes to the one element must be applied.") + .isEqualTo(""); + undoManager.undo(); + assertThat(document.get()).as("One undo must revert both changes to the one element.") + .isEqualTo(original); + }); + } + + @Test + void testSetCells_whenChangingSeveralRows_isOneUndoStepThatRestoresTheOriginalTextExactly() + throws Exception + { + final IDocument document = new Document( + ""); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.setCells("USERS", + List.of(new CellChange(0, "NAME", "Alicia"), new CellChange(1, "NAME", "Robert"))); + + assertThat(document.get()).as("Both rows must be changed.").isEqualTo( + ""); + undoManager.undo(); + assertThat(document.get()).as("One undo must restore the original text exactly.") + .isEqualTo(original); + }); + } + + @Test + void testSetCells_whenChangingMoreThan50Rows_changesTheDocumentOnceWithoutARewriteSession() + throws Exception + { + final StringBuilder xml = new StringBuilder(""); + final StringBuilder expected = new StringBuilder(""); + for (int i = 0; i < 60; i++) + { + xml.append(""); + expected.append(""); + } + xml.append(""); + expected.append(""); + final IDocument document = new Document(xml.toString()); + final String original = document.get(); + + withUndoManager(document, undoManager -> + { + final List sessionEvents = new ArrayList<>(); + ((IDocumentExtension4) document).addDocumentRewriteSessionListener(sessionEvents::add); + final DocumentChangeCounter changes = new DocumentChangeCounter(); + document.addDocumentListener(changes); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + final List manyChanges = new ArrayList<>(); + for (int i = 0; i < 60; i++) + { + manyChanges.add(new CellChange(i, "NAME", "Changed" + i)); + } + + datasetDocument.setCells("USERS", manyChanges); + + assertThat(document.get()).as("All changes must be applied.").isEqualTo(expected.toString()); + assertThat(changes.count).as("The call must change the document once, however many rows it " + + "changes.").isEqualTo(1); + assertThat(sessionEvents).as("The call must start no rewrite session, because a text viewer " + + "redraws its whole document when a session stops.").isEmpty(); + undoManager.undo(); + assertThat(document.get()).as("The change must be one undo step.").isEqualTo(original); + }); + } + + @Test + void testDeleteRows_whenDeletingMoreThan50ScatteredRows_changesTheDocumentOnceAndUndoRestoresIt() + throws Exception + { + final StringBuilder xml = new StringBuilder("\n"); + final StringBuilder expected = new StringBuilder("\n"); + final int[] oddRows = new int[60]; + for (int i = 0; i < 120; i++) + { + final String line = " \n"; + xml.append(line); + if (i % 2 == 0) + { + expected.append(line); + } + else + { + oddRows[i / 2] = i; + } + } + xml.append("\n"); + expected.append("\n"); + final IDocument document = new Document(xml.toString()); + final String original = document.get(); + + withUndoManager(document, undoManager -> + { + final DocumentChangeCounter changes = new DocumentChangeCounter(); + document.addDocumentListener(changes); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.deleteRows("USERS", oddRows); + + assertThat(document.get()).as("Every other row's line must be deleted and nothing else.") + .isEqualTo(expected.toString()); + assertThat(changes.count).as("Deleting many rows must change the document once.").isEqualTo(1); + undoManager.undo(); + assertThat(document.get()).as("One undo must restore the original text exactly.") + .isEqualTo(original); + }); + } + + @Test + void testSetCells_whenValueContainsSpecialCharacters_escapesThem() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NOTE", "a"); + } + + @Test + void testSetCells_whenValueIsUnchanged_doesNothingAndAddsNoUndoStep() throws Exception + { + final IDocument document = new Document(""); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", "Alice"))); + + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + assertThat(undoManager.undoable()).as("No undo step must be added for a no-op change.") + .isFalse(); + }); + } + + @Test + void testSetCells_whenStartTagSpansSeveralLines_keepsItsLayout() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", "Bob"))); + + assertThat(document.get()) + .as("Only the changed attribute's value changes; the multi-line layout stays.") + .isEqualTo(""); + } + + @Test + void testSetCells_whenDocumentHasCommentsAndCrLfLineEnds_preservesThem() + { + final IDocument document = new Document("\r\n \r\n" + + " \r\n\r\n"); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", "Bob"))); + + assertThat(document.get()).as("Comments and CR LF line ends elsewhere must be untouched.") + .isEqualTo("\r\n \r\n" + + " \r\n\r\n"); + } + + @Test + void testSetCells_whenCharsetCannotEncodeTheValue_usesACharacterReference() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(document, DtdSource.NONE, + FlatXmlOptions.DBUNIT_DEFAULTS, () -> StandardCharsets.ISO_8859_1); + datasetDocument.refresh(); + + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NOTE", "€"))); + + assertThat(document.get()).as( + "A character the document's encoder cannot represent must become a numeric character " + + "reference.") + .isEqualTo(""); + } + + @Test + void testSetCells_whenTheCharsetSupplierThrows_escapesForUtf8AndLogsAWarning() + { + final IDocument document = new Document(""); + final IllegalStateException failure = new IllegalStateException("The charset is unknown."); + final List logged = new ArrayList<>(); + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(document, DtdSource.NONE, + FlatXmlOptions.DBUNIT_DEFAULTS, () -> + { + throw failure; + }, logged::add); + datasetDocument.refresh(); + + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NOTE", "€"))); + + assertThat(document.get()).as("A failing charset supplier must fall back to UTF-8, which can " + + "represent the euro sign.").isEqualTo(""); + assertThat(logged).as("The failing charset supplier must be logged once, with its failure.") + .usingRecursiveComparison() + .isEqualTo(List.of(new Status(IStatus.WARNING, "org.dbunit.eclipse.dataset.core", + "The charset supplier failed, so the document escapes values for UTF-8.", failure))); + } + + @Test + void testSetCells_whenChangeWouldEmptyARow_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.setCells("USERS", List.of(new CellChange(0, "ID", null)))) + .as("Emptying a row's only value must be rejected.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testSetCells_whenRowHasTwoAttributesForTheColumnDifferingOnlyInCase_throwsAndChangesNothing() + { + final IDocument document = + new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy( + () -> datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", "Carol")))) + .as("Changing a cell must be rejected when its row has two attributes for the column, " + + "instead of silently overwriting both.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testSetCells_whenAttributesShareAKeyButAreNotEqualIgnoringCase_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy( + () -> datasetDocument.setCells("USERS", List.of(new CellChange(0, "straße", "3")))) + .as("Changing a cell must be rejected with the case-variant error, not fail on the " + + "lookup of its column, when its row has two attributes with the column's key.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testSetCells_whenSettingNullOnAColumnWithCaseVariantAttributes_throwsAndChangesNothing() + { + final IDocument document = + new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", null)))) + .as("Clearing a cell must be rejected when its row has two attributes for the column, " + + "instead of silently removing both.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testSetCells_whenEditingAnUnambiguousColumnInARowWithCaseVariantAttributes_changesOnlyThatAttribute() + { + final IDocument document = new Document( + ""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.setCells("USERS", List.of(new CellChange(0, "EMAIL", "b@x.org"))); + + assertThat(document.get()) + .as("A column without case-variant attributes must stay editable, and the ambiguous " + + "attributes must be left byte-identical.") + .isEqualTo(""); + } + + @Test + void testSetCells_whenValueContainsANonXmlCharacter_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy( + () -> datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", "\u0001")))) + .as("A value with a character outside the XML 1.0 Char range must be rejected.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testSetCells_afterADirectDocumentReplaceElsewhere_usesFreshOffsets() throws Exception + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + document.replace(document.get().indexOf(""); + + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", "Bob"))); + + assertThat(document.get()).as("setCells must refresh and use fresh offsets after an external change.") + .isEqualTo(""); + } + + @Test + void testSetCells_whenTheModelIsCurrent_copiesTheTextOnlyToParseTheEditedDocument() + { + final String text = ""; + final List changes = List.of(new CellChange(0, "NAME", "Bob")); + + final int copies = + copiesDuringEdit(text, datasetDocument -> datasetDocument.setCells("USERS", changes)); + + assertThat(copies).as("The edit must be planned on the text that the model was parsed from, so the " + + "only copy of the whole text is the one that parses the document after the edit.") + .isEqualTo(1); + } + + @Test + void testMoveRows_whenTheModelIsCurrent_copiesTheTextOnlyToParseTheEditedDocument() + { + final String text = ""; + + final int copies = + copiesDuringEdit(text, datasetDocument -> datasetDocument.moveRows("USERS", 0, 1, 1)); + + assertThat(copies).as("The edit must be planned on the text that the model was parsed from, so the " + + "only copy of the whole text is the one that parses the document after the edit.") + .isEqualTo(1); + } + + @Test + void testDuplicateRows_whenTheModelIsCurrent_copiesTheTextOnlyToParseTheEditedDocument() + { + final String text = ""; + + final int copies = copiesDuringEdit(text, + datasetDocument -> datasetDocument.duplicateRows("USERS", new int[] { 0 })); + + assertThat(copies).as("The edit must be planned on the text that the model was parsed from, so the " + + "only copy of the whole text is the one that parses the document after the edit.") + .isEqualTo(1); + } + + @Test + void testRenameColumn_whenTheModelIsCurrent_copiesTheTextOnlyToParseTheEditedDocument() + { + final String text = ""; + + final int copies = copiesDuringEdit(text, + datasetDocument -> datasetDocument.renameColumn("USERS", "NAME", "FULL_NAME")); + + assertThat(copies).as("The edit must be planned on the text that the model was parsed from, so the " + + "only copy of the whole text is the one that parses the document after the edit.") + .isEqualTo(1); + } + + @Test + void testInsertRows_whenInsertingBeforeTheFirstRow_placesTheNewRowThereWithMatchingIndentation() + throws Exception + { + final IDocument document = + new Document("\n \n \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.insertRows("USERS", 0, List.of(List.of("0"))); + + assertThat(document.get()) + .as("The new row must be inserted before the first, indented to match.") + .isEqualTo("\n \n \n" + + " \n\n"); + undoManager.undo(); + assertThat(document.get()).as("Undo must restore the original text.").isEqualTo(original); + }); + } + + @Test + void testInsertRows_whenInsertingInTheMiddle_placesTheNewRowBetweenItsNeighbors() throws Exception + { + final IDocument document = + new Document("\n \n \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.insertRows("USERS", 1, List.of(List.of("2"))); + + assertThat(document.get()).as("The new row must be inserted between its neighbors.") + .isEqualTo("\n \n \n" + + " \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testInsertRows_whenInsertingAfterTheLastRow_appendsTheNewRowWithMatchingIndentation() + throws Exception + { + final IDocument document = + new Document("\n \n \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.insertRows("USERS", 2, List.of(List.of("3"))); + + assertThat(document.get()) + .as("The new row must be appended after the last, indented to match.") + .isEqualTo("\n \n \n" + + " \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testSetCellsAndAppendRows_whenChangingTheLastRowAndAppending_rebuildsTheModelOnceInOneUndoStep() + throws Exception + { + final IDocument document = new Document("\n \n" + + " \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + final List refreshes = new ArrayList<>(); + datasetDocument.addModelListener(event -> refreshes.add(event.origin())); + + datasetDocument.setCellsAndAppendRows("USERS", List.of(new CellChange(1, "NAME", "Robert")), + List.of(List.of("3", "Carol"), Arrays.asList("4", null))); + + assertThat(document.get()) + .as("The last row must change, and the new rows must follow it, indented to match.") + .isEqualTo("\n \n" + + " \n \n" + + " \n\n"); + assertThat(refreshes).as("The model must be rebuilt once.").containsExactly(ChangeOrigin.EDIT); + undoManager.undo(); + assertThat(document.get()).as("One undo must restore the original text.").isEqualTo(original); + }); + } + + @Test + void testSetCellsAndAppendRows_whenAChangeWouldEmptyARow_throwsAndAppendsNothing() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.setCellsAndAppendRows("USERS", + List.of(new CellChange(0, "ID", null)), List.of(List.of("2")))) + .as("A change that would empty a row must be rejected.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("A rejected call must not append its rows either.") + .isEqualTo(""); + } + + @Test + void testSetCellsAndAppendRows_whenAnAppendedRowIsAllNull_throwsAndAppendsNothing() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.setCellsAndAppendRows("USERS", List.of(), + List.of(Arrays.asList((String) null)))) + .as("An appended row with no values must be rejected, the same as a change that would " + + "empty an existing row.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("A rejected call must not append its rows either.") + .isEqualTo(""); + } + + @Test + void testInsertRows_whenIndentationUsesTabs_copiesTabIndentation() throws Exception + { + final IDocument document = + new Document("\n\t\n\t\n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.insertRows("USERS", 1, List.of(List.of("3"))); + + assertThat(document.get()).as("The new row must copy the tab indentation.").isEqualTo( + "\n\t\n\t\n\t\n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testInsertRows_whenTableHasOnlyAMarker_replacesTheMarker() throws Exception + { + final IDocument document = new Document( + "\n\n" + + "\n]>\n\n \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.insertRows("USERS", 0, List.of(List.of("1"), List.of("2"))); + + assertThat(document.get()).as("The marker must be replaced by the new rows.").isEqualTo( + "\n\n" + + "\n]>\n\n" + + " \n \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testInsertRows_whenTheMarkerHoldsAComment_keepsTheCommentAndUndoRestoresTheMarker() throws Exception + { + final String declarations = "\n" + + "\n\n]>\n"; + final IDocument document = new Document(declarations + + "\n \n \n \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.insertRows("USERS", 0, List.of(List.of("1"), List.of("2"))); + + assertThat(document.get()).as("The comment of the marker must stay, after the new rows.") + .isEqualTo(declarations + "\n \n \n" + + " \n\n"); + undoManager.undo(); + assertThat(document.get()).as("Undo must bring the marker back.").isEqualTo(original); + }); + } + + @Test + void testInsertRows_whenTheMarkerHoldsOnlyWhitespace_replacesTheWholeElement() + { + final String declarations = "\n" + + "\n\n]>\n"; + final IDocument document = + new Document(declarations + "\n \n \n\n"); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.insertRows("USERS", 0, List.of(List.of("1"))); + + assertThat(document.get()).as("An element with nothing in it is replaced whole, without leftovers.") + .isEqualTo(declarations + "\n \n\n"); + } + + @Test + void testInsertRows_whenTheMarkerHoldsOnlyAnIdeographicSpace_keepsIt() + { + final String declarations = "\n" + + "\n\n]>\n"; + final IDocument document = + new Document(declarations + "\n \u3000\n\n"); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.insertRows("USERS", 0, List.of(List.of("1"))); + + assertThat(document.get()) + .as("The ideographic space is text to the XML parser, not whitespace, so what the marker " + + "held stays.") + .isEqualTo(declarations + "\n \u3000\n\n"); + } + + @Test + void testInsertRows_whenTheMarkerHoldsTextAndAProcessingInstruction_keepsBoth() + { + final String declarations = "\n" + + "\n\n]>\n"; + final IDocument document = new Document( + declarations + "\n note \n\n"); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.insertRows("USERS", 0, List.of(List.of("1"))); + + assertThat(document.get()).as("Whatever the marker held stays.") + .isEqualTo(declarations + "\n note \n\n"); + } + + @Test + void testInsertRows_whenTableIsDtdOnly_addsTheElementBeforeTheEndTag() throws Exception + { + final IDocument document = new Document( + "\n\n" + + "\n\n" + + "\n]>\n\n" + + " \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.insertRows("ORDERS", 0, List.of(List.of("5"))); + + assertThat(document.get()) + .as("The new row must be added as the last child, before the end tag.") + .isEqualTo("\n" + + "\n\n" + + "\n\n]>\n" + + "\n \n \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testInsertBlankRow_aboveTheFirstRowOfATableWithoutDtd_hasEveryColumnOfTheOldFirstRow() + throws Exception + { + final IDocument document = + new Document("\n \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.insertBlankRow("USERS", 0); + + assertThat(document.get()) + .as("The blank row becomes the first row, so it needs every column dbUnit takes from " + + "the old first row.") + .isEqualTo("\n \n" + + " \n\n"); + undoManager.undo(); + assertThat(document.get()).as("One undo step must remove the blank row.").isEqualTo(original); + }); + } + + @Test + void testInsertBlankRow_belowTheFirstRow_hasAnEmptyStringInTheFirstColumnOnly() throws Exception + { + final IDocument document = + new Document("\n \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.insertBlankRow("USERS", 1); + + assertThat(document.get()).as("A row after the first needs only a value that makes it a row.") + .isEqualTo("\n \n \n" + + "\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testInsertBlankRow_aboveTheFirstRowWhenTheDocumentHasADtd_hasAnEmptyStringInTheFirstColumnOnly() + throws Exception + { + final String text = "\n\n" + + "\n]>\n\n" + + " \n\n"; + final IDocument document = new Document(text); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.insertBlankRow("USERS", 0); + + assertThat(document.get()).as("With a DTD, the first row does not decide the columns.") + .isEqualTo(text.replace(" \n \n\n"; + final IDocument document = new Document(text); + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(document, DtdSource.NONE, + new FlatXmlOptions(false, true), () -> StandardCharsets.UTF_8); + datasetDocument.refresh(); + + datasetDocument.insertBlankRow("USERS", 0); + + assertThat(document.get()).as("With column sensing, the first row does not decide the columns.") + .isEqualTo(text.replace(" \n \n \n\n"); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.insertBlankRow("USERS", 0)) + .as("A row cannot be written without columns.").isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testSetCells_whenTheFirstRowWouldLoseAColumnThatAnotherRowHas_throwsAndChangesNothing() + { + final IDocument document = new Document("\n \n" + + " \n\n"); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", null)))) + .as("dbUnit would ignore Bob's name, so the edit must be refused.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testInsertRows_whenAllValuesAreNull_throwsAndChangesNothing() + { + final IDocument document = + new Document("\n \n\n"); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy( + () -> datasetDocument.insertRows("USERS", 1, List.of(Arrays.asList(null, null)))) + .as("An all-null new row must be rejected, the same as a change that would empty an " + + "existing row.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testInsertRows_whenInsertingSeveralRows_keepsTheGivenOrder() throws Exception + { + final IDocument document = new Document("\n \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.insertRows("USERS", 1, + List.of(List.of("2"), List.of("3"), List.of("4"))); + + assertThat(document.get()).as("Several new rows must keep the given order.").isEqualTo( + "\n \n \n \n" + + " \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testInsertRows_whenRowIndexIsOutOfRange_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.insertRows("USERS", 5, List.of(List.of("2")))) + .as("An out-of-range row index must be rejected with a message naming the row and table.") + .isInstanceOf(DatasetEditException.class) + .hasMessage("Row 5 is out of range for table 'USERS'."); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testDeleteRows_whenTheDatasetIsOneLongLine_doesNotScanTheLineForEachRow() + { + final StringBuilder text = new StringBuilder(""); + for (int row = 0; row < 40_000; row++) + { + text.append(""); + } + text.append(""); + final IDocument document = new Document(text.toString()); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + final int[] evenRows = IntStream.range(0, 20_000).map(row -> row * 2).toArray(); + + assertTimeoutPreemptively(Duration.ofSeconds(10), + () -> datasetDocument.deleteRows("USERS", evenRows), + "Deleting rows must not read the whole line before each row, which takes minutes."); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows()) + .as("Half of the rows must be left.").hasSize(20_000); + } + + @Test + void testDeleteRows_whenDeletingMiddleRowsAloneOnTheirLines_removesTheirWholeLines() throws Exception + { + final IDocument document = new Document("\n \n" + + " \n \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.deleteRows("USERS", new int[] { 1 }); + + assertThat(document.get()) + .as("Deleting a middle row alone on its line must remove the whole line.") + .isEqualTo("\n \n \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testDeleteRows_whenDeletingInlineElements_removesOnlyTheElements() throws Exception + { + final IDocument document = + new Document(""); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.deleteRows("USERS", new int[] { 1 }); + + assertThat(document.get()).as("Deleting an inline element must remove only that element.") + .isEqualTo(""); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testDeleteRows_whenDeletingAllRowsWithoutAMarker_leavesAMarkerElement() throws Exception + { + final IDocument document = + new Document("\n \n \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.deleteRows("USERS", new int[] { 0, 1 }); + + assertThat(document.get()).as("Deleting every row must leave a marker so the table stays.") + .isEqualTo("\n \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testDeleteRows_whenDeletingAllRowsOfATableThatHasAMarker_keepsOnlyTheMarker() throws Exception + { + final IDocument document = new Document("\n \n \n" + + " \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.deleteRows("USERS", new int[] { 0, 1 }); + + assertThat(document.get()) + .as("With an existing marker, deleting all rows must not add another.") + .isEqualTo("\n \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testDeleteRows_whenDeletingAllRows_keepsTheColumnsAsPendingColumns() + { + final IDocument document = new Document("\n \n" + + " \n\n"); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.deleteRows("USERS", new int[] { 0, 1 }); + + final DatasetTable users = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(users.getColumns()) + .as("The text keeps a column only in the attributes of rows, so the table must keep the " + + "columns of its deleted rows as pending columns.") + .containsExactly(new DatasetColumn("ID", false, false, true), + new DatasetColumn("NAME", false, false, true)); + assertThat(users.getRows()).as("No row may be left.").isEmpty(); + } + + @Test + void testDeleteRows_whenDeletingAllRowsOfATableThatHasAMarker_keepsTheColumnsAsPendingColumns() + { + final IDocument document = new Document("\n \n" + + " \n \n\n"); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.deleteRows("USERS", new int[] { 0, 1 }); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("A marker keeps the table, not its columns, so they must be pending columns too.") + .containsExactly(new DatasetColumn("ID", false, false, true), + new DatasetColumn("NAME", false, false, true)); + } + + @Test + void testDeleteRows_whenDeletingAllRowsOfATableWithAPendingColumn_keepsTheColumnsInTheirOrder() + { + final IDocument document = + new Document("\n \n\n"); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "EXTRA"); + + datasetDocument.deleteRows("USERS", new int[] { 0 }); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("The columns that the rows gave the table must come before the one that was pending, " + + "as they did in the grid.") + .containsExactly(new DatasetColumn("ID", false, false, true), + new DatasetColumn("NAME", false, false, true), + new DatasetColumn("EXTRA", false, false, true)); + } + + @Test + void testDeleteRows_whenDeletingAllRowsOfATableTheDtdDeclares_keepsOnlyTheUndeclaredColumnsAsPending() + { + final IDocument document = new Document("\n" + + "\n\n]>\n" + + "\n \n\n"); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.deleteRows("USERS", new int[] { 0 }); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("The DTD keeps the columns that it declares; the column that only the row used must " + + "stay as a pending column.") + .containsExactly(new DatasetColumn("ID", true, false, false), + new DatasetColumn("NAME", true, false, false), + new DatasetColumn("EXTRA", false, false, true)); + } + + @Test + void testDeleteRows_whenDeletingAllRowsOfATableWithDefaultValues_keepsTheUndeclaredColumnsAsPending() + { + final IDocument document = new Document("\n" + + "\n\n]>\n" + + "\n \n\n"); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.deleteRows("USERS", new int[] { 0 }); + + final DatasetTable users = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(users.isDeclaredOnly()).as("The DTD alone keeps the table, with no empty element.") + .isTrue(); + assertThat(users.getColumns()).as("The column that only the row used must stay as a pending column.") + .containsExactly(new DatasetColumn("ID", true, false, false), + new DatasetColumn("STATUS", true, false, false, "ACTIVE"), + new DatasetColumn("EXTRA", false, false, true)); + } + + @Test + void testDeleteRows_whenSomeRowsRemain_keepsNoPendingColumn() + { + final IDocument document = new Document("\n \n" + + " \n\n"); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.deleteRows("USERS", new int[] { 1 }); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("Only deleting every row keeps the columns; here one is gone with the only row that " + + "had it, as it is when its last value is set to NULL.") + .containsExactly(new DatasetColumn("ID", false, true, false), + new DatasetColumn("NAME", false, true, false)); + } + + @Test + void testInsertBlankRow_afterDeletingAllRows_usesTheKeptColumns() + { + final IDocument document = new Document("\n \n" + + " \n\n"); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.deleteRows("USERS", new int[] { 0, 1 }); + + datasetDocument.insertBlankRow("USERS", 0); + + assertThat(document.get()).as("A row must be insertable again, in the first of the kept columns.") + .isEqualTo("\n \n\n"); + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("The column that the new row gave a value is a data column again, and the other is " + + "still pending.") + .containsExactly(new DatasetColumn("ID", false, true, false), + new DatasetColumn("NAME", false, false, true)); + } + + @Test + void testDeleteRows_whenDeletingAllRowsIsUndoneAndRedone_theColumnsFollowTheRows() throws Exception + { + final IDocument document = new Document("\n \n" + + " \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.deleteRows("USERS", new int[] { 0, 1 }); + + undoManager.undo(); + datasetDocument.refresh(); + + assertThat(document.get()).as("Undo must bring the rows back.").isEqualTo(original); + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("Undo must leave no pending column, because the rows have the columns again.") + .containsExactly(new DatasetColumn("ID", false, true, false), + new DatasetColumn("NAME", false, true, false)); + + undoManager.redo(); + datasetDocument.refresh(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("Redo must keep the columns as pending columns, as the first delete did.") + .containsExactly(new DatasetColumn("ID", false, false, true), + new DatasetColumn("NAME", false, false, true)); + }); + } + + @Test + void testDeleteRows_whenDeletingAllRowsAndThenInsertingARow_undoAndRedoKeepTheColumnsRight() + throws Exception + { + final IDocument document = new Document("\n \n" + + " \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.deleteRows("USERS", new int[] { 0, 1 }); + datasetDocument.insertBlankRow("USERS", 0); + + undoManager.undo(); + undoManager.undo(); + datasetDocument.refresh(); + + assertThat(document.get()).as("Two undos must undo both steps.").isEqualTo(original); + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("Undo must leave no pending column.") + .containsExactly(new DatasetColumn("ID", false, true, false), + new DatasetColumn("NAME", false, true, false)); + + undoManager.redo(); + undoManager.redo(); + datasetDocument.refresh(); + + assertThat(document.get()).as("Two redos must apply both steps again.") + .isEqualTo("\n \n\n"); + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("The column that the new row gave a value is a data column, and the other is " + + "still pending.") + .containsExactly(new DatasetColumn("ID", false, true, false), + new DatasetColumn("NAME", false, false, true)); + }); + } + + @Test + void testDeleteRows_whenARowIndexIsOutOfRange_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.deleteRows("USERS", new int[] { 5 })) + .as("An out-of-range row index must be rejected.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testDuplicateRows_whenDuplicatingSelectedRows_copiesTheirTextVerbatimAfterTheLastSelected() + throws Exception + { + final IDocument document = new Document("\n \n" + + " \n \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.duplicateRows("USERS", new int[] { 0, 2 }); + + assertThat(document.get()) + .as("Duplicates must be inserted verbatim, in row order, after the last selected row.") + .isEqualTo("\n \n \n" + + " \n \n" + + " \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testDuplicateRows_whenARowIndexIsOutOfRange_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.duplicateRows("USERS", new int[] { 5 })) + .as("An out-of-range row index must be rejected.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testMoveRows_whenMovingDown_swapsWithTheNextRow() throws Exception + { + final IDocument document = + new Document("\n \n \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.moveRows("USERS", 0, 1, 1); + + assertThat(document.get()).as("Moving down must swap the row with its next neighbor.") + .isEqualTo("\n \n \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testMoveRows_whenMovingUp_swapsWithThePreviousRow() throws Exception + { + final IDocument document = + new Document("\n \n \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.moveRows("USERS", 1, 1, -1); + + assertThat(document.get()).as("Moving up must swap the row with its previous neighbor.") + .isEqualTo("\n \n \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testMoveRows_whenMovingABlockDown_movesTheWholeBlockPastItsNeighbor() throws Exception + { + final IDocument document = new Document("\n \n" + + " \n \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.moveRows("USERS", 0, 2, 1); + + assertThat(document.get()) + .as("Moving a block down must move it past its next neighbor as a unit.") + .isEqualTo("\n \n \n" + + " \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testMoveRows_whenTableSegmentsAreInterleavedWithAnotherTable_stillRotatesCorrectly() + throws Exception + { + final IDocument document = new Document("\n \n" + + " \n \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.moveRows("USERS", 0, 1, 1); + + assertThat(document.get()) + .as("Moving must rotate element texts in place, working across an interleaved " + + "segment.") + .isEqualTo("\n \n \n" + + " \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testMoveRows_whenMovingPastTheFirstRow_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.moveRows("USERS", 0, 1, -1)) + .as("Moving the first row up must be rejected.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testMoveRows_whenMovingPastTheLastRow_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.moveRows("USERS", 1, 1, 1)) + .as("Moving the last row down must be rejected.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testSetCells_whenEveryChangeKeepsTheValueOfItsCell_changesNoTextAndAddsNoUndoStep() throws Exception + { + final String original = + ""; + final IDocument document = new Document(original); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.setCells("USERS", + List.of(new CellChange(0, "NAME", "AB"), new CellChange(1, "NAME", "a&b"))); + + assertThat(document.get()) + .as("Setting a cell to the value that it has must not rewrite its text.") + .isEqualTo(original); + assertThat(undoManager.undoable()).as("Nothing changed, so there is nothing to undo.").isFalse(); + }); + } + + @Test + void testSetCells_whenOneChangeKeepsTheValueAndAnotherChangesIt_rewritesOnlyTheChangedCell() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.setCells("USERS", + List.of(new CellChange(0, "NAME", "AB"), new CellChange(0, "ID", "7"))); + + assertThat(document.get()).as("The cell that keeps its value keeps its text.") + .isEqualTo(""); + } + + @Test + void testAddColumn_whenColumnDoesNotExist_addsAPendingColumnWithoutChangingText() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.addColumn("USERS", "NAME"); + + assertThat(document.get()).as("Adding a column must not change the text.").isEqualTo(original); + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + final int columnIndex = table.getColumnIndex("NAME"); + assertThat(table.getColumns().get(columnIndex)) + .as("The new column must be pending, undeclared, and without values.") + .isEqualTo(new DatasetColumn("NAME", false, false, true)); + } + + @Test + void testAddColumn_whenSettingAValueInIt_becomesADataColumnAndDropsThePendingEntry() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "NAME"); + + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", "Alice"))); + + assertThat(document.get()).as("Setting the value must write the new attribute.") + .isEqualTo(""); + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + final int columnIndex = table.getColumnIndex("NAME"); + assertThat(table.getColumns().get(columnIndex)) + .as("The column must become a plain data column once it has a value.") + .isEqualTo(new DatasetColumn("NAME", false, true, false)); + } + + @Test + void testAddColumn_whenNameIsInvalid_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.addColumn("USERS", "1BAD")) + .as("An invalid column name must be rejected.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumnIndex("1BAD")) + .as("No pending column must have been recorded.").isEqualTo(-1); + } + + @Test + void testAddColumn_whenNameAlreadyExists_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.addColumn("USERS", "name")) + .as("A name that already exists, case-insensitively, must be rejected.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testAddColumn_whenAnEarlierEditExists_undoRemovesOnlyThePendingColumn() throws Exception + { + final IDocument document = new Document(""); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", "Bob"))); + final String afterCellEdit = document.get(); + + datasetDocument.addColumn("USERS", "EXTRA"); + + undoManager.undo(); + assertThat(document.get()) + .as("One undo must remove the pending column, not the earlier cell edit.") + .isEqualTo(afterCellEdit); + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumnIndex("EXTRA")) + .as("The pending column must be gone after undo.").isEqualTo(-1); + undoManager.undo(); + assertThat(document.get()).as("A second undo must revert the earlier cell edit.") + .isEqualTo(original); + }); + } + + @Test + void testAddColumn_whenPendingAddIsUndone_redoRestoresIt() throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "EXTRA"); + + undoManager.undo(); + undoManager.redo(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getColumns().get(table.getColumnIndex("EXTRA"))) + .as("Redo must bring the pending column back.") + .isEqualTo(new DatasetColumn("EXTRA", false, false, true)); + }); + } + + @Test + void testSetCells_whenFillingAPendingColumnIsUndone_theColumnStaysPending() throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "NAME"); + + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", "Alice"))); + undoManager.undo(); + datasetDocument.refresh(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getColumns().get(table.getColumnIndex("NAME"))) + .as("Undoing the typed value must leave the column pending again, not remove it.") + .isEqualTo(new DatasetColumn("NAME", false, false, true)); + }); + } + + @Test + void testAddColumn_whenFilledAndBothStepsAreUndoneBeforeAnyRefresh_theColumnIsGone() throws Exception + { + final IDocument document = new Document(""); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "NAME"); + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", "Alice"))); + + undoManager.undo(); + undoManager.undo(); + + assertThat(document.get()).as("Both undo steps must bring back the original text.") + .isEqualTo(original); + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("Undoing the add must remove the column, though the typed value's undo left " + + "the model without a refresh.") + .containsExactly(new DatasetColumn("ID", false, true, false)); + }); + } + + @Test + void testAddColumn_whenFilledAndBothStepsAreUndoneBeforeAnyRefresh_redoOfTheAddLeavesNoTwinToRename() + throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "NAME"); + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", "Alice"))); + undoManager.undo(); + undoManager.undo(); + + undoManager.redo(); + datasetDocument.renameColumn("USERS", "NAME", "OTHER"); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("Redo of the add must list the column once, or the rename leaves a twin behind " + + "under the old name.") + .containsExactly(new DatasetColumn("ID", false, true, false), + new DatasetColumn("OTHER", false, false, true)); + }); + } + + @Test + void testAddColumn_whenFilledAndBothStepsAreUndoneAndRedoneBeforeAnyRefresh_theColumnHoldsTheValue() + throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "NAME"); + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", "Alice"))); + undoManager.undo(); + undoManager.undo(); + + undoManager.redo(); + undoManager.redo(); + datasetDocument.refresh(); + + assertThat(document.get()).as("Redoing both steps must bring back the typed value.") + .isEqualTo(""); + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("The column must be a data column once, without a pending twin.") + .containsExactly(new DatasetColumn("ID", false, true, false), + new DatasetColumn("NAME", false, true, false)); + }); + } + + @Test + void testDeleteColumn_whenAnotherColumnWasFilledAndBothStepsAreUndoneBeforeAnyRefresh_bothAreBack() + throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "FIRST"); + datasetDocument.addColumn("USERS", "SECOND"); + datasetDocument.deleteColumn("USERS", "SECOND"); + datasetDocument.setCells("USERS", List.of(new CellChange(0, "FIRST", "x"))); + + undoManager.undo(); + undoManager.undo(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("Undoing the delete must bring the deleted column back after the other one, " + + "though the typed value's undo left the model without a refresh.") + .containsExactly(new DatasetColumn("ID", false, true, false), + new DatasetColumn("FIRST", false, false, true), + new DatasetColumn("SECOND", false, false, true)); + }); + } + + @Test + void testDeleteColumn_whenAReloadedDtdNowDeclaresTheOtherColumn_undoPutsTheDeletedColumnBack() + throws Exception + { + final IDocument document = new Document( + ""); + final MutableDtdSource dtdSource = new MutableDtdSource( + ""); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(document, dtdSource, + FlatXmlOptions.DBUNIT_DEFAULTS, () -> StandardCharsets.UTF_8); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "FIRST"); + datasetDocument.addColumn("USERS", "SECOND"); + datasetDocument.deleteColumn("USERS", "SECOND"); + dtdSource.setText("" + + ""); + datasetDocument.reloadDtd(); + + undoManager.undo(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("Undo must put the deleted column back, though the reload dropped the other " + + "pending column from the list because the DTD declares it now.") + .containsExactly(new DatasetColumn("ID", true, true, false), + new DatasetColumn("FIRST", true, false, false), + new DatasetColumn("SECOND", false, false, true)); + }); + } + + @Test + void testRefresh_whenTheTextIsNotWellFormedForAWhile_keepsThePendingColumnsOfTheTablesAfterTheError() + throws Exception + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("T", "C"); + + document.replace(document.get().indexOf(""); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "NAME"); + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", "Alice"))); + undoManager.undo(); + + document.replace(document.get().indexOf(""), 0, ""); + datasetDocument.refresh(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("The column must stay pending, though a new text change followed the undo of its " + + "value before anything refreshed.") + .containsExactly(new DatasetColumn("ID", false, true, false), + new DatasetColumn("NAME", false, false, true)); + }); + } + + @Test + void testSetCells_whenManyEditsFollowTheFillOfAPendingColumn_undoingAllOfThemMakesItPendingAgain() + throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + undoManager.setMaximalUndoLevel(1_000); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "EXTRA"); + datasetDocument.setCells("USERS", List.of(new CellChange(0, "EXTRA", "x"))); + final int laterEdits = 250; + for (int edit = 0; edit < laterEdits; edit++) + { + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", "Name" + edit))); + } + + for (int step = 0; step <= laterEdits; step++) + { + undoManager.undo(); + } + datasetDocument.refresh(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("The undo of the edit that filled the column must leave it pending, however many " + + "edits came after it.") + .containsExactly(new DatasetColumn("ID", false, true, false), + new DatasetColumn("NAME", false, true, false), + new DatasetColumn("EXTRA", false, false, true)); + }); + } + + @Test + void testRefresh_whenNoUndoHistoryExistsAndManyRefreshesFollow_doesNotRestoreTheOldestState() + throws Exception + { + final String original = ""; + final Document document = new Document(original); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "EXTRA"); + final long oldestStamp = document.getModificationStamp(); + for (int edit = 0; edit < 250; edit++) + { + datasetDocument.setCells("USERS", List.of(new CellChange(0, "ID", "v" + edit))); + } + datasetDocument.setCells("USERS", List.of(new CellChange(0, "EXTRA", "x"))); + + document.set(original, oldestStamp); + datasetDocument.refresh(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getColumnIndex("EXTRA")) + .as("Without an undo history, only the latest 200 snapshots are kept, so the column that " + + "belonged to the oldest state must not come back from a snapshot.") + .isEqualTo(-1); + } + + @Test + void testRenameTable_whenUndoneAndRedoneBeforeAnyRefresh_thePendingColumnFollowsTheTable() + throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "EXTRA"); + datasetDocument.renameTable("USERS", "CUSTOMERS"); + + undoManager.undo(); + undoManager.redo(); + datasetDocument.refresh(); + + assertThat(datasetDocument.getModel().findTable("CUSTOMERS").orElseThrow().getColumns()) + .as("The redo of the rename must leave the pending column with the renamed table.") + .containsExactly(new DatasetColumn("ID", false, true, false), + new DatasetColumn("EXTRA", false, false, true)); + }); + } + + @Test + void testRefresh_whenTheExternalDtdIsNotLoadedForAWhile_keepsThePendingColumnsOfATableOnlyItDeclares() + { + final IDocument document = + new Document(""); + final AtomicBoolean failing = new AtomicBoolean(); + final DtdSource source = (publicId, systemId) -> + { + if (failing.get()) + { + throw new IllegalStateException("The DTD source is broken."); + } + return Optional.of( + ""); + }; + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(document, source, + FlatXmlOptions.DBUNIT_DEFAULTS, () -> StandardCharsets.UTF_8); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "NOTES"); + + failing.set(true); + datasetDocument.reloadDtd(); + assertThat(datasetDocument.getModel().findTable("USERS")) + .as("Without the DTD the model must not list the table that only the DTD declares.") + .isEmpty(); + failing.set(false); + datasetDocument.reloadDtd(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("The pending column must survive the DTD being unreadable for a moment.") + .containsExactly(new DatasetColumn("ID", true, false, false), + new DatasetColumn("NOTES", false, false, true)); + } + + @Test + void testRenameColumn_whenColumnHasMatchingAttributes_renamesEveryOccurrenceCaseInsensitively() + throws Exception + { + final IDocument document = new Document("\n \n" + + " \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.renameColumn("USERS", "NAME", "FULL_NAME"); + + assertThat(document.get()) + .as("Every attribute matching the column, regardless of case, must be renamed and " + + "nothing else changed.") + .isEqualTo("\n \n" + + " \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testRenameColumn_whenColumnIsPending_renamesThePendingEntryWithoutChangingText() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "EXTRA"); + + datasetDocument.renameColumn("USERS", "EXTRA", "NOTES"); + + assertThat(document.get()).as("Renaming a pending column must not change the text.") + .isEqualTo(original); + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getColumnIndex("EXTRA")).as("The old pending name must be gone.").isEqualTo(-1); + assertThat(table.getColumns().get(table.getColumnIndex("NOTES"))) + .as("The pending column must be renamed in place.") + .isEqualTo(new DatasetColumn("NOTES", false, false, true)); + } + + @Test + void testRenameColumn_whenPendingColumnsAreEqualIgnoringCaseYetDistinct_renamesOnlyTheNamedOne() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "ID"); + datasetDocument.addColumn("USERS", "\u0130D"); + + datasetDocument.renameColumn("USERS", "\u0130D", "CODE"); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("Renaming one pending column must not rename another whose name equals it only " + + "ignoring case.") + .containsExactly(new DatasetColumn("NAME", false, true, false), + new DatasetColumn("ID", false, false, true), + new DatasetColumn("CODE", false, false, true)); + } + + @Test + void testRenameColumn_whenPendingRenameIsUndone_restoresTheOldPendingName() throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "EXTRA"); + + datasetDocument.renameColumn("USERS", "EXTRA", "NOTES"); + undoManager.undo(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getColumnIndex("NOTES")).as("The new pending name must be gone.") + .isEqualTo(-1); + assertThat(table.getColumns().get(table.getColumnIndex("EXTRA"))) + .as("Undo must restore the old pending name.") + .isEqualTo(new DatasetColumn("EXTRA", false, false, true)); + }); + } + + @Test + void testRenameColumn_whenNewNameIsInvalid_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.renameColumn("USERS", "NAME", "1BAD")) + .as("An invalid new column name must be rejected.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testRenameColumn_whenNewNameAlreadyExists_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.renameColumn("USERS", "NAME", "id")) + .as("Renaming to a name that already exists, case-insensitively, must be rejected.") + .isInstanceOf(DatasetEditException.class) + .hasMessage("Table 'USERS' already has a column named 'id'."); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testRenameColumn_whenAnElementHasTwoAttributesForTheColumnDifferingOnlyInCase_throwsAndChangesNothing() + { + final IDocument document = + new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.renameColumn("USERS", "NAME", "FULL_NAME")) + .as("Renaming must be rejected when a row has two attributes for the column.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testRenameColumn_whenColumnIsDeclaredWithoutValues_throwsAndChangesNothing() + { + final IDocument document = new Document( + "\n\n" + + "\n]>\n" + + "\n \n\n"); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.renameColumn("USERS", "NAME", "FULL_NAME")) + .as("A declared column with no values must be rejected instead of silently doing " + + "nothing.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testDeleteColumn_whenColumnExistsInMultipleRows_removesTheAttributeEverywhere() throws Exception + { + final IDocument document = new Document("\n \n" + + " \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.deleteColumn("USERS", "NAME"); + + assertThat(document.get()).as("The attribute must be removed from every row.") + .isEqualTo("\n \n \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testDeleteColumn_whenARowSpellsTheColumnInTwoWays_removesBothSpellings() + { + final IDocument document = + new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.deleteColumn("USERS", "NAME"); + + assertThat(document.get()).as("The two spellings are one column, so deleting it must remove both.") + .isEqualTo(""); + } + + @Test + void testDeleteColumn_whenItIsARowsOnlyValue_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.deleteColumn("USERS", "NAME")) + .as("Deleting a row's only value must be rejected.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testDeleteColumn_whenColumnIsPending_dropsItWithoutChangingText() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "EXTRA"); + + datasetDocument.deleteColumn("USERS", "EXTRA"); + + assertThat(document.get()).as("Deleting a pending column must not change the text.") + .isEqualTo(original); + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumnIndex("EXTRA")) + .as("The pending column must be gone.").isEqualTo(-1); + } + + @Test + void testDeleteColumn_whenPendingColumnsAreEqualIgnoringCaseYetDistinct_dropsAndRestoresOnlyTheNamedOne() + throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "ID"); + datasetDocument.addColumn("USERS", "\u0130D"); + final List beforeDelete = + datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns(); + + datasetDocument.deleteColumn("USERS", "\u0130D"); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("Deleting one pending column must not drop another whose name equals it only " + + "ignoring case.") + .containsExactly(new DatasetColumn("NAME", false, true, false), + new DatasetColumn("ID", false, false, true)); + + undoManager.undo(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("Undo must restore the deleted pending column after the one it is equal to " + + "only ignoring case.") + .isEqualTo(beforeDelete); + }); + } + + @Test + void testDeleteColumn_whenPendingColumnDeleteIsUndone_restoresItAtItsOriginalPosition() + throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "FIRST"); + datasetDocument.addColumn("USERS", "SECOND"); + datasetDocument.addColumn("USERS", "THIRD"); + final List beforeDelete = + datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns(); + + datasetDocument.deleteColumn("USERS", "SECOND"); + undoManager.undo(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getColumns()) + .as("Undo must restore the deleted pending column at its original position.") + .isEqualTo(beforeDelete); + }); + } + + @Test + void testDeleteColumn_whenColumnIsDtdDeclared_keepsItInTheModel() throws Exception + { + final IDocument document = new Document( + "\n\n" + + "\n]>\n" + + "\n \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.deleteColumn("USERS", "NAME"); + + assertThat(document.get()).as("The attribute must be removed from the row.").isEqualTo( + "\n\n" + + "\n]>\n" + + "\n \n\n"); + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + final int columnIndex = table.getColumnIndex("NAME"); + assertThat(table.getColumns().get(columnIndex)) + .as("A DTD-declared column must stay in the model even without values.") + .isEqualTo(new DatasetColumn("NAME", true, false, false)); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testAddTable_whenTheEndTagIsAloneOnItsLine_insertsAtTheStartOfThatLine() throws Exception + { + final IDocument document = new Document("\n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.addTable("USERS", List.of()); + + assertThat(document.get()) + .as("The table must be added at the start of the end tag's own line.") + .isEqualTo("\n \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testAddTable_whenTheEndTagSharesItsLineWithOtherContent_insertsBeforeTheEndTag() + throws Exception + { + final IDocument document = new Document("\n "); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.addTable("USERS", List.of()); + + assertThat(document.get()) + .as("The table must be added on its own line, right before the end tag.") + .isEqualTo("\n \n \n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testAddTable_whenRootIsSelfClosing_replacesItWithAnOpenAndCloseTag() throws Exception + { + final IDocument document = new Document("\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.addTable("USERS", List.of()); + + assertThat(document.get()) + .as("A self-closing root must become an open and close tag around the new table.") + .isEqualTo("\n \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testAddTable_whenRootIsSelfClosingWithAttributes_keepsThemOnTheOpenTag() throws Exception + { + final IDocument document = new Document("\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.addTable("USERS", List.of()); + + assertThat(document.get()) + .as("The root's attributes must survive turning a self-closing root into an open tag.") + .isEqualTo("\n \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testAddTable_whenATableAlreadyHasRows_insertsAfterThemUsingTheirIndentation() throws Exception + { + final IDocument document = new Document("\n \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.addTable("USERS", List.of()); + + assertThat(document.get()) + .as("The table must be added after existing rows, matching their indentation.") + .isEqualTo("\n \n \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testAddTable_whenColumnNamesAreGiven_recordsThemAsPendingColumns() throws Exception + { + final IDocument document = new Document("\n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.addTable("ITEMS", List.of("SKU", "QTY")); + + assertThat(document.get()).isEqualTo("\n \n\n"); + final DatasetTable table = datasetDocument.getModel().findTable("ITEMS").orElseThrow(); + assertThat(table.getColumns()).as("Both given names must be recorded as pending columns.") + .containsExactly(new DatasetColumn("SKU", false, false, true), + new DatasetColumn("QTY", false, false, true)); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testAddTable_whenCreationIsUndoneAndTheNameIsReused_hasNoLeftoverPendingColumns() + throws Exception + { + final IDocument document = new Document("\n\n"); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addTable("ITEMS", List.of("SKU", "QTY")); + + undoManager.undo(); + datasetDocument.refresh(); + + assertThat(datasetDocument.getModel().findTable("ITEMS")) + .as("The undone table must be gone from the model.").isEmpty(); + datasetDocument.addTable("ITEMS", List.of()); + + final DatasetTable table = datasetDocument.getModel().findTable("ITEMS").orElseThrow(); + assertThat(table.getColumns()) + .as("The undone table's pending columns must not leak into the reused name.") + .isEmpty(); + }); + } + + @Test + void testAddTable_whenAdditionIsUndoneAndRedone_thePendingColumnsComeBack() throws Exception + { + final IDocument document = new Document("\n\n"); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addTable("ITEMS", List.of("SKU", "QTY")); + + undoManager.undo(); + datasetDocument.refresh(); + assertThat(datasetDocument.getModel().findTable("ITEMS")) + .as("Undo must remove the added table.").isEmpty(); + + undoManager.redo(); + datasetDocument.refresh(); + + final DatasetTable table = datasetDocument.getModel().findTable("ITEMS").orElseThrow(); + assertThat(table.getColumns()) + .as("Redo must bring the table's pending columns back with it.") + .containsExactly(new DatasetColumn("SKU", false, false, true), + new DatasetColumn("QTY", false, false, true)); + }); + } + + @Test + void testAddTable_whenNameIsInvalid_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.addTable("1BAD", List.of())) + .as("An invalid table name must be rejected.").isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testAddTable_whenNameAlreadyExists_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.addTable("users", List.of())) + .as("A name that already exists, case-insensitively, must be rejected.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testAddTable_whenAnExistingTableIsEqualIgnoringCaseYetDistinct_addsTheTable() + { + final IDocument document = new Document("<\u0130TEMS ID=\"1\"/>"); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.addTable("ITEMS", List.of()); + + assertThat(datasetDocument.getModel().getTables()).extracting(DatasetTable::getKey) + .as("A table whose key differs from that of every existing table must be added, although " + + "its name equals one of theirs ignoring case.") + .containsExactly("\u0130TEMS", "ITEMS"); + } + + @Test + void testAddTable_whenAnExistingTableSharesTheKeyYetIsNotEqualIgnoringCase_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.addTable("STRASSE", List.of())) + .as("A name with the key of an existing table must be rejected, although the two names are " + + "not equal ignoring case.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testAddTable_whenNameIsDataset_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.addTable("dataset", List.of())) + .as("The reserved name 'dataset' must be rejected.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testAddTable_whenAColumnNameIsInvalid_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.addTable("ORDERS", List.of("1st col"))) + .as("An invalid column name must be rejected.").isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + assertThat(datasetDocument.getModel().findTable("ORDERS")).as("No table must have been added.") + .isEmpty(); + } + + @Test + void testAddTable_whenColumnNamesHaveADuplicateCaseInsensitively_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.addTable("ORDERS", List.of("ID", "id"))) + .as("A duplicate column name, case-insensitively, must be rejected.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + assertThat(datasetDocument.getModel().findTable("ORDERS")).as("No table must have been added.") + .isEmpty(); + } + + @Test + void testAddTable_whenColumnNamesAreEqualIgnoringCaseYetDistinct_recordsBoth() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.addTable("ORDERS", List.of("ID", "\u0130D")); + + assertThat(datasetDocument.getModel().findTable("ORDERS").orElseThrow().getColumns()) + .as("Column names with different keys must both be recorded, although they are equal " + + "ignoring case.") + .containsExactly(new DatasetColumn("ID", false, false, true), + new DatasetColumn("\u0130D", false, false, true)); + } + + @Test + void testAddTable_whenColumnNamesShareAKeyYetAreNotEqualIgnoringCase_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.addTable("ORDERS", List.of("straße", "STRASSE"))) + .as("Two column names with the same key must be rejected as duplicates, although they " + + "are not equal ignoring case.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + assertThat(datasetDocument.getModel().findTable("ORDERS")).as("No table must have been added.") + .isEmpty(); + } + + @Test + void testRenameTable_whenElementsAreSelfClosing_renamesTheStartTagAcrossAllSegments() + throws Exception + { + final IDocument document = new Document("\n \n" + + " \n \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.renameTable("USERS", "CUSTOMERS"); + + assertThat(document.get()) + .as("Every segment of the table, wherever it appears, must be renamed.") + .isEqualTo("\n \n \n" + + " \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testRenameTable_whenAnElementHasAnExplicitEndTag_renamesBothTags() throws Exception + { + final IDocument document = new Document("\n \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.renameTable("USERS", "CUSTOMERS"); + + assertThat(document.get()).as("Both the start and end tag names must be replaced.") + .isEqualTo("\n \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testRenameTable_whenListenersAreNotified_theEventSaysWhichTableWasRenamed() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + final List changes = new ArrayList<>(); + datasetDocument.addModelListener(event -> changes.add(event.tableChanges())); + + datasetDocument.renameTable("USERS", "CUSTOMERS"); + + assertThat(changes).as("The event of the rename must say which table has the new name.") + .containsExactly(new TableChanges(Map.of("USERS", "CUSTOMERS"), List.of())); + } + + @Test + void testRenameTable_whenUndone_theEventSaysThatTheTableHasItsOldNameAgain() throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.renameTable("USERS", "CUSTOMERS"); + final List changes = new ArrayList<>(); + datasetDocument.addModelListener(event -> changes.add(event.tableChanges())); + + undoManager.undo(); + datasetDocument.refresh(); + + assertThat(changes).as("An undo is no edit of the dataset document, and its event must say " + + "which table has the old name again.") + .containsExactly(new TableChanges(Map.of("CUSTOMERS", "USERS"), List.of())); + }); + } + + @Test + void testRefresh_whenTheTextRenamesATable_theEventSaysWhichTableWasRenamed() throws Exception + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + final List changes = new ArrayList<>(); + datasetDocument.addModelListener(event -> changes.add(event.tableChanges())); + + document.set(""); + datasetDocument.refresh(); + + assertThat(changes).as("A rename that was typed in the text is one change of the model.") + .containsExactly(new TableChanges(Map.of("USERS", "CUSTOMERS"), List.of())); + } + + @Test + void testSetOptions_whenTableNamesBecomeCaseSensitive_theEventSaysWhichTablesHaveNewKeys() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + final List changes = new ArrayList<>(); + datasetDocument.addModelListener(event -> changes.add(event.tableChanges())); + + datasetDocument.setOptions(new FlatXmlOptions(true, false)); + + assertThat(changes).as("The table is the same, and the event must say that its key changed.") + .containsExactly(new TableChanges(Map.of("USERS", "users"), List.of())); + } + + @Test + void testAddTable_whenListenersAreNotified_theEventSaysWhichTableWasAdded() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + final List changes = new ArrayList<>(); + datasetDocument.addModelListener(event -> changes.add(event.tableChanges())); + + datasetDocument.addTable("ORDERS", List.of("ID")); + + assertThat(changes).as("The event of the new table must list its key.") + .containsExactly(new TableChanges(Map.of(), List.of("ORDERS"))); + } + + @Test + void testRenameTable_whenColumnsArePending_movesThemToTheNewKey() throws Exception + { + final IDocument document = new Document(""); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "EXTRA"); + + datasetDocument.renameTable("USERS", "CUSTOMERS"); + + assertThat(document.get()).isEqualTo(""); + assertThat(datasetDocument.getModel().findTable("USERS")) + .as("The old table key must be gone.").isEmpty(); + final DatasetTable table = datasetDocument.getModel().findTable("CUSTOMERS").orElseThrow(); + assertThat(table.getColumns()) + .as("The pending column must move to the renamed table.") + .contains(new DatasetColumn("EXTRA", false, false, true)); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testRenameTable_whenUndone_thePendingColumnAddedBeforeItSurvivesUnderTheOldName() + throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "EXTRA"); + + datasetDocument.renameTable("USERS", "CUSTOMERS"); + undoManager.undo(); + datasetDocument.refresh(); + + final DatasetTable tableAfterRenameUndo = + datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(tableAfterRenameUndo.getColumns()) + .as("Undoing the rename must bring the pending column back under the old table name.") + .contains(new DatasetColumn("EXTRA", false, false, true)); + + undoManager.undo(); + datasetDocument.refresh(); + + final DatasetTable tableAfterColumnUndo = + datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(tableAfterColumnUndo.getColumnIndex("EXTRA")) + .as("A second undo, of the column add, must still remove the pending column.") + .isEqualTo(-1); + }); + } + + @Test + void testRenameTable_whenTheInternalSubsetDeclaresTheTable_renamesTheDeclarationsInTheSameUndoStep() + throws Exception + { + final String original = "\n" + + "\n\n" + + "\n\n]>\n" + + "\n \n \n\n"; + final IDocument document = new Document(original); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.renameTable("USERS", "ACCOUNTS"); + + assertThat(document.get()).as("The elements and the DTD must take the new name together.") + .isEqualTo(original.replace("USERS", "ACCOUNTS")); + final DatasetModel model = datasetDocument.getModel(); + assertThat(model.getTables()) + .as("The renamed table must replace the old one, and no table must be left that only " + + "the DTD declares.") + .extracting(DatasetTable::getName).containsExactly("ACCOUNTS", "ORDERS"); + assertThat(model.getProblems()) + .as("The DTD must still declare every table of the dataset.").isEmpty(); + undoManager.undo(); + assertThat(document.get()).as("One undo must restore the elements and the DTD together.") + .isEqualTo(original); + }); + } + + @Test + void testRefresh_whenTheDoctypeNamesAnotherRoot_showsTheTablesOfItsContentModel() + { + final IDocument document = new Document("\n" + + "\n\n" + + "\n\n]>\n" + + "\n"); + final FlatXmlDatasetDocument datasetDocument = create(document); + + datasetDocument.refresh(); + + assertThat(datasetDocument.getModel().getTables()).extracting(DatasetTable::getName) + .as("dbUnit takes the name of the DOCTYPE for the root, so the tables are the ones that its " + + "content model lists, the table without rows included.") + .containsExactly("USERS", "ORDERS"); + } + + @Test + void testRenameTable_whenTheDoctypeNamesAnotherRoot_renamesTheTableInTheContentModelOfThatRoot() + { + final String original = "\n" + + "\n\n]>\n" + + "\n \n\n"; + final IDocument document = new Document(original); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.renameTable("USERS", "ACCOUNTS"); + + assertThat(document.get()).as("The content model of the root that the DOCTYPE names must follow.") + .isEqualTo(original.replace("USERS", "ACCOUNTS")); + assertThat(datasetDocument.getModel().getProblems()).as("The DTD must still list the table.") + .isEmpty(); + } + + @Test + void testRenameTable_whenNewNameIsInvalid_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.renameTable("USERS", "1BAD")) + .as("An invalid new table name must be rejected.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testRenameTable_whenNewNameAlreadyExists_throwsAndChangesNothing() + { + final IDocument document = + new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.renameTable("USERS", "orders")) + .as("A new name that already exists, case-insensitively, must be rejected.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testRenameTable_whenAnotherTableIsEqualIgnoringCaseYetDistinct_renamesTheTable() + { + final IDocument document = + new Document("<\u0130TEMS ID=\"9\"/>"); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.renameTable("USERS", "ITEMS"); + + assertThat(document.get()) + .as("A new name whose key differs from that of every other table must be accepted, " + + "although it equals the name of one of them ignoring case.") + .isEqualTo("<\u0130TEMS ID=\"9\"/>"); + } + + @Test + void testRenameTable_whenAnotherTableSharesTheKeyYetIsNotEqualIgnoringCase_throwsAndChangesNothing() + { + final IDocument document = + new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.renameTable("USERS", "STRASSE")) + .as("A new name with the key of another table must be rejected, because the rename would " + + "merge the two tables, although the names are not equal ignoring case.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testRenameTable_whenNewNameIsDataset_throwsAndChangesNothing() + { + final IDocument document = new Document(""); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.renameTable("USERS", "dataset")) + .as("The reserved name 'dataset' must be rejected.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testRenameTable_whenTableIsDeclaredOnly_throwsAndChangesNothing() + { + final IDocument document = new Document( + "\n\n" + + "\n]>\n\n\n"); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.renameTable("USERS", "CUSTOMERS")) + .as("A declared-only table must be rejected instead of silently doing nothing.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testDeleteTable_whenAnotherTableIsInterleaved_leavesItByteIdentical() throws Exception + { + final IDocument document = new Document("\n \n" + + " \n \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.deleteTable("USERS"); + + assertThat(document.get()) + .as("The other table's element must be left byte-identical.") + .isEqualTo("\n \n\n"); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testDeleteTable_whenTableIsDtdDeclared_leavesADeclaredOnlyTable() throws Exception + { + final IDocument document = new Document( + "\n\n" + + "\n]>\n\n" + + " \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.deleteTable("USERS"); + + assertThat(document.get()).as("The table's only element must be removed.").isEqualTo( + "\n\n" + + "\n]>\n\n\n"); + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.isDeclaredOnly()) + .as("A DTD-declared table must remain, as declared-only.").isTrue(); + assertThat(table.getColumns()).as("Its declared column must still be listed.") + .containsExactly(new DatasetColumn("ID", true, false, false)); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testDeleteRows_whenEveryRowOfATableWithDefaultValuesIsDeleted_leavesADeclaredOnlyTable() + throws Exception + { + final IDocument document = new Document( + "\n\n" + + "\n]>\n\n" + + " \n \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.deleteRows("USERS", new int[] { 0, 1 }); + + assertThat(document.get()) + .as("No empty element may be left: dbUnit loads it as a row of default values.") + .isEqualTo("\n\n" + + "\n]>\n\n" + + "\n"); + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows()).as("The table must have no rows left.").isEmpty(); + assertThat(table.isDeclaredOnly()).as("The DTD must keep the table, as declared-only.").isTrue(); + assertThat(table.getColumns()).as("The table must keep its columns and their defaults.") + .containsExactly(new DatasetColumn("ID", true, false, false), + new DatasetColumn("STATUS", true, false, false, "ACTIVE")); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testSetCells_whenTheRowIsAnEmptyElementOfDefaultValues_addsTheAttributeToIt() throws Exception + { + final IDocument document = new Document( + "\n\n" + + "\n]>\n\n" + + " \n\n"); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.setCells("USERS", List.of(new CellChange(0, "ID", "5"))); + + assertThat(document.get()).as("The empty element is the table's row, so it takes the value.") + .isEqualTo("\n\n" + + "\n]>\n\n" + + " \n\n"); + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows().get(0).getValues()) + .as("The row must hold the new value and still no value for the defaulted column.") + .containsExactly("5", null); + undoManager.undo(); + assertThat(document.get()).isEqualTo(original); + }); + } + + @Test + void testDeleteTable_whenTableIsDeclaredOnly_throwsAndChangesNothing() + { + final IDocument document = new Document( + "\n\n" + + "\n]>\n\n\n"); + final String original = document.get(); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + assertThatThrownBy(() -> datasetDocument.deleteTable("USERS")) + .as("A declared-only table must be rejected instead of silently doing nothing.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @Test + void testDeleteTable_whenUndone_itsPendingColumnsComeBack() throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + datasetDocument.addColumn("USERS", "EXTRA"); + + datasetDocument.deleteTable("USERS"); + undoManager.undo(); + datasetDocument.refresh(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getColumns()) + .as("Undoing the delete must bring the table's pending column back.") + .contains(new DatasetColumn("EXTRA", false, false, true)); + }); + } + + private static void withUndoManager(final IDocument document, final UndoManagerConsumer consumer) + throws Exception + { + DocumentUndoManagerRegistry.connect(document); + final IDocumentUndoManager undoManager = DocumentUndoManagerRegistry.getDocumentUndoManager(document); + undoManager.connect(FlatXmlDatasetDocumentTest.class); + try + { + consumer.accept(undoManager); + } + finally + { + undoManager.disconnect(FlatXmlDatasetDocumentTest.class); + DocumentUndoManagerRegistry.disconnect(document); + } + } + + private static FlatXmlDatasetDocument create(final IDocument document) + { + return new FlatXmlDatasetDocument(document, DtdSource.NONE, FlatXmlOptions.DBUNIT_DEFAULTS, + () -> StandardCharsets.UTF_8); + } + + /** + * Returns how often an edit copies the whole text out of a document whose model is current. + */ + private static int copiesDuringEdit(final String text, final Consumer edit) + { + final CountingDocument document = new CountingDocument(text); + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + final int copiesBefore = document.copies; + + edit.accept(datasetDocument); + + return document.copies - copiesBefore; + } + + /** + * A document that counts how often its whole text is copied out of it. + */ + private static final class CountingDocument extends Document + { + private int copies; + + CountingDocument(final String text) + { + super(text); + } + + @Override + public String get() + { + copies++; + return super.get(); + } + } + + private static final class MutableDtdSource implements DtdSource + { + private String text; + + private MutableDtdSource(final String text) + { + this.text = text; + } + + private void setText(final String newText) + { + this.text = newText; + } + + @Override + public Optional load(final String publicId, final String systemId) + { + return Optional.of(text); + } + } + + @FunctionalInterface + private interface UndoManagerConsumer + { + void accept(IDocumentUndoManager undoManager) throws Exception; + } + + /** + * Counts the changes of a document. + */ + private static final class DocumentChangeCounter implements IDocumentListener + { + private int count; + + @Override + public void documentAboutToBeChanged(final DocumentEvent event) + { + } + + @Override + public void documentChanged(final DocumentEvent event) + { + count++; + } + } + + @Test + void testRefresh_whenTheContentModelIsEmpty_dbUnitFailsToLoadTheDatasetAndTheModelReportsIt( + @TempDir final Path tempDir) throws Exception + { + final String text = "\n]>\n\n"; + final Path file = tempDir.resolve("empty-content-model.xml"); + Files.writeString(file, text, StandardCharsets.UTF_8); + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(new Document(text), + DtdSource.NONE, FlatXmlOptions.DBUNIT_DEFAULTS, () -> StandardCharsets.UTF_8); + datasetDocument.refresh(); + + assertThatThrownBy(() -> new FlatXmlDataSetBuilder().build(file.toFile())) + .as("dbUnit must fail to load a dataset whose DTD declares the dataset element EMPTY.") + .isInstanceOf(DataSetException.class); + assertThat(datasetDocument.getModel().getProblems()).extracting(DatasetProblem::code) + .as("The model must report the failure, and not as a table named EMPTY.") + .containsExactly(ProblemCode.DTD_EMPTY_CONTENT_MODEL); + assertThat(datasetDocument.getModel().getTables()).as("The keyword EMPTY is no table.").isEmpty(); + } + + @Test + void testRefresh_whenTheDtdListsTwoSpellingsOfATable_dbUnitFailsToLoadTheDatasetAndTheModelReportsIt( + @TempDir final Path tempDir) throws Exception + { + final Path file = tempDir.resolve("table-name-case-variants.xml"); + Files.writeString(file, CASE_VARIANT_TABLES_DATASET, StandardCharsets.UTF_8); + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument( + new Document(CASE_VARIANT_TABLES_DATASET), DtdSource.NONE, FlatXmlOptions.DBUNIT_DEFAULTS, + () -> StandardCharsets.UTF_8); + datasetDocument.refresh(); + + assertThatThrownBy(() -> new FlatXmlDataSetBuilder().build(file.toFile())) + .as("dbUnit must fail to load a DTD that lists two spellings of one table.") + .isInstanceOf(DataSetException.class); + assertThat(datasetDocument.getModel().getProblems()).extracting(DatasetProblem::code) + .as("The model must report the failure.") + .containsExactly(ProblemCode.DTD_TABLE_NAME_CASE_VARIANTS); + assertThat(datasetDocument.getModel().getTables()).extracting(DatasetTable::getName) + .as("The two spellings are one table, as in dbUnit.").containsExactly("users"); + } + + @Test + void testRefresh_whenTheDtdListsTwoSpellingsOfATableAndNamesAreCaseSensitive_dbUnitStillFailsToLoadIt( + @TempDir final Path tempDir) throws Exception + { + final Path file = tempDir.resolve("table-name-case-variants.xml"); + Files.writeString(file, CASE_VARIANT_TABLES_DATASET, StandardCharsets.UTF_8); + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument( + new Document(CASE_VARIANT_TABLES_DATASET), DtdSource.NONE, new FlatXmlOptions(true, false), + () -> StandardCharsets.UTF_8); + datasetDocument.refresh(); + + assertThatThrownBy( + () -> new FlatXmlDataSetBuilder().setCaseSensitiveTableNames(true).build(file.toFile())) + .as("dbUnit keeps the tables of a DTD in a map that ignores letter case.") + .isInstanceOf(DataSetException.class); + assertThat(datasetDocument.getModel().getProblems()).extracting(DatasetProblem::code) + .as("The model must report the failure whatever the case sensitivity of table names.") + .containsExactly(ProblemCode.DTD_TABLE_NAME_CASE_VARIANTS); + } + + @Test + void testRefresh_whenTheDtdDeclaresOnlyAnotherNameThanTheDoctypesAsRoot_dbUnitFailsToLoadTheRows( + @TempDir final Path tempDir) throws Exception + { + final String text = "\n\n" + + "\n]>\n\n"; + final Path file = tempDir.resolve("other-root-name.xml"); + Files.writeString(file, text, StandardCharsets.UTF_8); + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(new Document(text), + DtdSource.NONE, FlatXmlOptions.DBUNIT_DEFAULTS, () -> StandardCharsets.UTF_8); + datasetDocument.refresh(); + + assertThatThrownBy(() -> new FlatXmlDataSetBuilder().build(file.toFile())) + .as("dbUnit takes the name of the DOCTYPE for the root, and finds no tables in the DTD.") + .isInstanceOf(DataSetException.class); + assertThat(datasetDocument.getModel().getProblems()).extracting(DatasetProblem::code) + .as("The model must report that the DTD does not list the table, as dbUnit finds it.") + .containsExactly(ProblemCode.TABLE_NOT_DECLARED_IN_DTD); + } + + @Test + void testInsertBlankRow_aboveTheFirstRow_leavesDbUnitLoadingEveryColumn( + @TempDir final Path tempDir) throws Exception + { + final IDocument document = + new Document("\n \n\n"); + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(document, DtdSource.NONE, + FlatXmlOptions.DBUNIT_DEFAULTS, () -> StandardCharsets.UTF_8); + datasetDocument.refresh(); + + datasetDocument.insertBlankRow("USERS", 0); + + final Path file = tempDir.resolve("blank-row.xml"); + Files.writeString(file, document.get(), StandardCharsets.UTF_8); + DbUnitParity.assertParity(document.get(), file.toFile(), false, true); + final ITable dbUnitTable = + new FlatXmlDataSetBuilder().setColumnSensing(false).build(file.toFile()).getTable("USERS"); + assertThat(dbUnitTable.getValue(1, "NAME")) + .as("dbUnit must still load the name of the old first row.").isEqualTo("Alice"); + } + + @Test + void testRenameTable_whenTheInternalSubsetDeclaresTheTable_leavesDbUnitLoadingTheRenamedTable( + @TempDir final Path tempDir) throws Exception + { + final File file = renameInFixture(tempDir, "dtd-defaults-internal.xml", "USERS", "ACCOUNTS"); + + final String text = Files.readString(file.toPath(), StandardCharsets.UTF_8); + DbUnitParity.assertParity(text, file, false, false); + final IDataSet dbUnitDataSet = new FlatXmlDataSetBuilder().setColumnSensing(false).build(file); + assertThat(dbUnitDataSet.getTableNames()) + .as("dbUnit must find the renamed table, which its DTD still declares.") + .containsExactly("ACCOUNTS", "ORDERS", "AUDIT"); + assertThat(dbUnitDataSet.getTable("ACCOUNTS").getValue(0, "STATUS")) + .as("dbUnit must still apply the default that the renamed table's ATTLIST declares.") + .isEqualTo("ACTIVE"); + } + + @Test + void testRenameTable_whenOnlyTheLetterCaseChanges_leavesDbUnitApplyingTheDtdDefaults( + @TempDir final Path tempDir) throws Exception + { + final File file = renameInFixture(tempDir, "dtd-defaults-internal.xml", "USERS", "Users"); + + final String text = Files.readString(file.toPath(), StandardCharsets.UTF_8); + DbUnitParity.assertParity(text, file, false, false); + final ITable dbUnitTable = + new FlatXmlDataSetBuilder().setColumnSensing(false).build(file).getTable("Users"); + assertThat(dbUnitTable.getValue(0, "STATUS")).as( + "dbUnit gives a DTD's defaults only to elements spelled like the DTD's element, so the " + + "DTD must have taken the new spelling too.") + .isEqualTo("ACTIVE"); + } + + /** + * Renames a table of a fixture through a dataset document and saves the resulting text to a file, so + * that dbUnit can load what the editor wrote. + */ + private static File renameInFixture(final Path tempDir, final String fixtureName, + final String tableKey, final String newTableName) + throws Exception + { + final IDocument document = new Document(TestDatasets.read(fixtureName)); + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(document, DtdSource.NONE, + FlatXmlOptions.DBUNIT_DEFAULTS, () -> StandardCharsets.UTF_8); + datasetDocument.refresh(); + + datasetDocument.renameTable(tableKey, newTableName); + + final Path file = tempDir.resolve("renamed-table.xml"); + Files.writeString(file, document.get(), StandardCharsets.UTF_8); + return file.toFile(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDoctypeTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDoctypeTest.java new file mode 100644 index 00000000..670fb1e4 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDoctypeTest.java @@ -0,0 +1,90 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link FlatXmlDoctype}. + */ +class FlatXmlDoctypeTest +{ + private static FlatXmlDoctype doctype(final String publicId, final String systemId, + final String internalSubset) + { + return new FlatXmlDoctype("dataset", publicId, systemId, internalSubset, 30, 0, 60); + } + + @Test + void testMayDeclareEntities_whenTheDoctypeHasNoIdentifierAndNoInternalSubset_isFalse() + { + final FlatXmlDoctype doctype = doctype(null, null, null); + + assertThat(doctype.mayDeclareEntities()).as("Nothing in the declaration can declare an entity.") + .isFalse(); + } + + @Test + void testMayDeclareEntities_whenTheDoctypeHasASystemIdentifier_isTrue() + { + final FlatXmlDoctype doctype = doctype(null, "dataset.dtd", null); + + assertThat(doctype.mayDeclareEntities()).as("The external DTD may declare an entity.").isTrue(); + } + + @Test + void testMayDeclareEntities_whenTheDoctypeHasAPublicIdentifier_isTrue() + { + final FlatXmlDoctype doctype = doctype("-//DbUnit//DTD Dataset//EN", "dataset.dtd", null); + + assertThat(doctype.mayDeclareEntities()).as("The external DTD may declare an entity.").isTrue(); + } + + @Test + void testMayDeclareEntities_whenTheInternalSubsetDeclaresAnEntity_isTrue() + { + final FlatXmlDoctype doctype = + doctype(null, null, "\n\n"); + + assertThat(doctype.mayDeclareEntities()).as("The internal subset declares an entity.").isTrue(); + } + + @Test + void testMayDeclareEntities_whenTheInternalSubsetDeclaresAParameterEntity_isTrue() + { + final FlatXmlDoctype doctype = doctype(null, null, "\">\n"); + + assertThat(doctype.mayDeclareEntities()) + .as("A parameter entity may bring in declarations, so entities may be declared.").isTrue(); + } + + @Test + void testMayDeclareEntities_whenTheInternalSubsetDeclaresOnlyElementsAndAttributes_isFalse() + { + final FlatXmlDoctype doctype = doctype(null, null, + "\n\n\n"); + + assertThat(doctype.mayDeclareEntities()) + .as("Element and attribute list declarations declare no entity.").isFalse(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlElementTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlElementTest.java new file mode 100644 index 00000000..3b73af7d --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlElementTest.java @@ -0,0 +1,88 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link FlatXmlElement}: the check for attributes that differ only in letter case. + */ +class FlatXmlElementTest +{ + private static FlatXmlAttribute attribute(final String name) + { + return new FlatXmlAttribute(name, name.toUpperCase(Locale.ENGLISH), "v", 0, 0, 0, 0, '"'); + } + + private static FlatXmlElement elementWith(final String... attributeNames) + { + final List attributes = Arrays.stream(attributeNames) + .map(FlatXmlElementTest::attribute).toList(); + return new FlatXmlElement("USERS", 0, 6, attributes, 0, 0, true, -1, 0); + } + + @Test + void testHasCaseVariantAttributes_whenTheElementHasNoAttributes_isFalse() + { + assertThat(elementWith().hasCaseVariantAttributes("ID")).as("There is nothing to compare.").isFalse(); + } + + @Test + void testHasCaseVariantAttributes_whenOneAttributeMatches_isFalse() + { + assertThat(elementWith("id", "NAME").hasCaseVariantAttributes("ID")) + .as("A single attribute is not a variant of anything.").isFalse(); + } + + @Test + void testHasCaseVariantAttributes_whenTwoAttributesDifferOnlyInCase_isTrue() + { + assertThat(elementWith("ID", "NAME", "id").hasCaseVariantAttributes("ID")) + .as("ID and id are two spellings of one column.").isTrue(); + } + + @Test + void testHasCaseVariantAttributes_whenThreeAttributesMatch_isTrue() + { + assertThat(elementWith("Id", "iD", "ID").hasCaseVariantAttributes("ID")) + .as("Three spellings are more than one.").isTrue(); + } + + @Test + void testHasCaseVariantAttributes_whenOnlyAnotherColumnHasVariants_isFalse() + { + assertThat(elementWith("ID", "name", "NAME").hasCaseVariantAttributes("ID")) + .as("Variants of other columns must not count.").isFalse(); + } + + @Test + void testHasCaseVariantAttributes_whenTheKeyIsNotUpperCase_isFalse() + { + assertThat(elementWith("ID", "id").hasCaseVariantAttributes("id")) + .as("The key is compared with the upper-cased names, so it must be upper-cased.").isFalse(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlIndexTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlIndexTest.java new file mode 100644 index 00000000..3074bd58 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlIndexTest.java @@ -0,0 +1,96 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link FlatXmlIndex}: the first element of a table, which is a row or a marker, whichever comes + * first in the document. + */ +class FlatXmlIndexTest +{ + private static FlatXmlIndex indexOf(final String text) + { + final FlatXmlParseResult parse = FlatXmlParser.parse(text); + return FlatXmlModelBuilder.build(text, parse, null, FlatXmlOptions.DBUNIT_DEFAULTS, Map.of()).index(); + } + + @Test + void testGetFirstElement_whenAMarkerComesBeforeTheRows_returnsTheMarker() + { + final String text = ""; + final FlatXmlIndex index = indexOf(text); + + final FlatXmlElement first = index.getFirstElement("USERS"); + + assertThat(first.offset()).as("The marker is the first element of the table.") + .isEqualTo(text.indexOf("")); + assertThat(first).as("It must be the one that the ordered list starts with.") + .isEqualTo(index.getAllElementsInOrder("USERS").get(0)); + } + + @Test + void testGetFirstElement_whenARowComesBeforeTheMarkers_returnsTheRow() + { + final String text = ""; + final FlatXmlIndex index = indexOf(text); + + final FlatXmlElement first = index.getFirstElement("USERS"); + + assertThat(first.offset()).as("The first row is the first element of the table.") + .isEqualTo(text.indexOf(""; + + final FlatXmlElement first = indexOf(text).getFirstElement("USERS"); + + assertThat(first.offset()).as("The first row is the first element.") + .isEqualTo(text.indexOf("").getFirstElement("ORDERS")) + .as("A table that the document lacks has no first element.").isNull(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlModelBuilderTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlModelBuilderTest.java new file mode 100644 index 00000000..eb280c1f --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlModelBuilderTest.java @@ -0,0 +1,571 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.dbunit.dataset.ITable; +import org.dbunit.dataset.NoSuchColumnException; +import org.dbunit.dataset.xml.FlatXmlDataSetBuilder; +import org.dbunit.eclipse.dataset.core.TestDatasets; +import org.dbunit.eclipse.dataset.core.dtd.DtdDeclarations; +import org.dbunit.eclipse.dataset.core.dtd.DtdReader; +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.dbunit.eclipse.dataset.core.model.DatasetModel; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Tests {@link FlatXmlModelBuilder} against the table and column grouping rules of the Model Builder and + * Mapping sections of the flat XML specification, and against real dbUnit 3.5.2: it compares the model built + * from each "parity" fixture with the dataset that dbUnit loads from the same file, so that the editor shows + * exactly what dbUnit would read. + */ +class FlatXmlModelBuilderTest +{ + @Test + void testBuild_whenTableNamesDifferOnlyInCase_groupsThemUnderTheFirstSpelling() + { + final DatasetModel model = + buildWithoutDtd("", + FlatXmlOptions.DBUNIT_DEFAULTS, Map.of()); + + assertThat(model.getTables()).as("Both spellings must group into one table.").hasSize(1); + final DatasetTable table = model.getTables().get(0); + assertThat(table.getName()).as("The display name must be the first spelling.").isEqualTo("USERS"); + assertThat(table.getRows()).as("Both elements' rows must be kept.").hasSize(2); + } + + @Test + void testBuild_whenCaseSensitiveTableNames_keepsSpellingsSeparate() + { + final DatasetModel model = buildWithoutDtd( + "", new FlatXmlOptions(true, false), + Map.of()); + + assertThat(model.getTables()).as("Case-sensitive table names must keep them separate.") + .extracting(DatasetTable::getName).containsExactly("USERS", "users"); + } + + @Test + void testBuild_whenATableIsSplitAcrossInterleavedSegments_mergesRowsInDocumentOrder() + { + final DatasetModel model = buildWithoutDtd( + "", + FlatXmlOptions.DBUNIT_DEFAULTS, Map.of()); + + final DatasetTable users = model.findTable("USERS").orElseThrow(); + assertThat(users.getRows()).as("Interleaved segments must merge in document order.") + .extracting(row -> row.getValue(users.getColumnIndex("ID"))).containsExactly("1", "2"); + } + + @Test + void testBuild_whenAttributesAppearInDifferentOrderAcrossRows_ordersColumnsByFirstAppearance() + { + final DatasetModel model = buildWithoutDtd( + "" + + "", + FlatXmlOptions.DBUNIT_DEFAULTS, Map.of()); + + final DatasetTable users = model.findTable("USERS").orElseThrow(); + assertThat(users.getColumns()).as("Columns must be ordered by first appearance.") + .extracting(DatasetColumn::name).containsExactly("ID", "NAME", "EMAIL"); + } + + @Test + void testBuild_whenATableHasOnlyMarkerElements_isAnEmptyTable() + { + final DatasetModel model = buildWithoutDtd("", + FlatXmlOptions.DBUNIT_DEFAULTS, Map.of()); + + final DatasetTable auditLog = model.findTable("AUDIT_LOG").orElseThrow(); + assertThat(auditLog.getRows()).as("A marker element declares the table but is not a row.") + .isEmpty(); + } + + @Test + void testBuild_whenTableIsDeclaredInDtd_ordersDeclaredColumnsFirstThenUndeclared() + { + final DatasetModel model = buildWithDoctype( + "\n\n" + + "\n]>\n" + + "", + FlatXmlOptions.DBUNIT_DEFAULTS, Map.of()); + + final DatasetTable users = model.findTable("USERS").orElseThrow(); + assertThat(users.getColumns()).as( + "Declared columns must come first in DTD order, then undeclared data columns.") + .extracting(DatasetColumn::name).containsExactly("ID", "NAME", "EXTRA"); + assertThat(users.getColumns().get(0).declared()).as("ID must be marked declared.").isTrue(); + assertThat(users.getColumns().get(2).declared()).as("EXTRA must not be marked declared.") + .isFalse(); + } + + @Test + void testBuild_whenADtdTableHasNoElements_isAppendedLastAsDeclaredOnly() + { + final DatasetModel model = buildWithDoctype( + "\n" + + "\n\n" + + "\n\n]>\n" + + "", + FlatXmlOptions.DBUNIT_DEFAULTS, Map.of()); + + assertThat(model.getTables()).as("ORDERS must be appended after USERS.") + .extracting(DatasetTable::getName).containsExactly("USERS", "ORDERS"); + final DatasetTable orders = model.findTable("ORDERS").orElseThrow(); + assertThat(orders.isDeclaredOnly()).as("A DTD table without elements is declared-only.").isTrue(); + assertThat(orders.getRows()).as("A declared-only table has no rows.").isEmpty(); + } + + @Test + void testBuild_whenDtdContentModelRepeatsATableNameWithDifferentCase_isOneDeclaredOnlyTable() + { + final DatasetModel model = buildWithDoctype( + "\n]>\n", + FlatXmlOptions.DBUNIT_DEFAULTS, Map.of()); + + assertThat(model.getTables()) + .as("A case-insensitive duplicate in the content model must yield one table, not two.") + .extracting(DatasetTable::getName).containsExactly("users"); + assertThat(model.getTables().get(0).isDeclaredOnly()) + .as("A DTD-only table with no elements is declared-only.").isTrue(); + } + + @Test + void testBuild_whenTheDtdDeclaresTwoSpellingsOfATable_theFirstOneGivesTheColumnsWithOrWithoutRows() + { + final String doctype = "\n" + + "\n\n" + + "\n\n]>\n"; + + final DatasetModel withoutRows = + buildWithDoctype(doctype + "", FlatXmlOptions.DBUNIT_DEFAULTS, Map.of()); + final DatasetModel withARow = buildWithDoctype(doctype + "", + FlatXmlOptions.DBUNIT_DEFAULTS, Map.of()); + + assertThat(List.of(declaredColumnNames(withoutRows, "USERS"), declaredColumnNames(withARow, "USERS"))) + .as("The declared columns must be the first spelling's, A, whether or not the table has " + + "rows, and not switch when the first row is added.") + .containsExactly(List.of("A"), List.of("A")); + } + + private static List declaredColumnNames(final DatasetModel model, final String tableKey) + { + final List columns = model.findTable(tableKey).orElseThrow().getColumns(); + return columns.stream().filter(DatasetColumn::declared).map(DatasetColumn::name).toList(); + } + + @Test + void testBuild_whenAnElementIsDeclaredOutsideTheContentModel_isNeitherADtdTableNorDeclaredOnly() + { + final DatasetModel model = buildWithDoctype( + "\n" + + "\n\n" + + "\n]>\n" + + "", + FlatXmlOptions.DBUNIT_DEFAULTS, Map.of()); + + assertThat(model.getTables()).as( + "AUDIT_LOG is declared but outside the content model, so it must not appear at all.") + .extracting(DatasetTable::getName).containsExactly("USERS"); + } + + @Test + void testBuild_whenAnExternalDtdDeclaresATable_marksItDeclaredInTheExternalDtd() + { + final DatasetModel model = buildWithExternalDtd("\n\n" + + "\n\n" + + "\n]>\n" + + "", + "\n", + FlatXmlOptions.DBUNIT_DEFAULTS); + + assertThat(model.getTables()) + .as("The tables that the external DTD declares, with or without elements in the document, " + + "must be marked, and the one that only the internal subset declares must not.") + .extracting(DatasetTable::getName, DatasetTable::isDeclaredInExternalDtd) + .containsExactly(tuple("USERS", true), tuple("ORDERS", false), tuple("PETS", true)); + } + + @Test + void testBuild_whenAnExternalDtdSpellsATableInAnotherCase_marksItIfNamesAreCaseInsensitive() + { + final String text = "\n" + + "\n]>\n"; + + final DatasetModel model = buildWithExternalDtd(text, "", + FlatXmlOptions.DBUNIT_DEFAULTS); + + assertThat(model.findTable("USERS").orElseThrow().isDeclaredInExternalDtd()) + .as("Users and USERS are one table, so the external declaration counts.").isTrue(); + } + + @Test + void testBuild_whenAnExternalDtdSpellsATableInAnotherCase_doesNotMarkItIfNamesAreCaseSensitive() + { + final String text = "\n" + + "\n]>\n"; + + final DatasetModel model = buildWithExternalDtd(text, "", + new FlatXmlOptions(true, false)); + + assertThat(model.findTable("USERS").orElseThrow().isDeclaredInExternalDtd()) + .as("Users and USERS are two tables, so the external declaration is not USERS'.").isFalse(); + } + + @Test + void testBuild_whenTwoAttributesDifferOnlyInCase_theLastOnesValueWins() + { + final DatasetModel model = buildWithoutDtd("", + FlatXmlOptions.DBUNIT_DEFAULTS, Map.of()); + + final DatasetTable users = model.findTable("USERS").orElseThrow(); + assertThat(users.getColumns()).as("id and ID must be one column.").hasSize(1); + assertThat(users.getRows().get(0).getValue(0)).as("The last attribute's value must win.") + .isEqualTo("2"); + } + + @Test + void testBuild_whenALaterRowSpellsAColumnInAnotherCase_fillsTheColumnFromIt() + { + final DatasetModel model = buildWithoutDtd("", + FlatXmlOptions.DBUNIT_DEFAULTS, Map.of()); + + final DatasetTable users = model.findTable("USERS").orElseThrow(); + assertThat(users.getColumns()).as("Both spellings must be one column, named after the first.") + .extracting(DatasetColumn::name).containsExactly("ID"); + assertThat(users.getRows()).as("The second row's id attribute must fill the ID column.") + .extracting(row -> row.getValue(0)).containsExactly("1", "2"); + } + + @Test + void testBuild_whenARowSpellsADtdColumnInAnotherCase_fillsTheDeclaredColumnFromIt() + { + final DatasetModel model = buildWithDoctype( + "\n\n" + + "\n]>\n" + + "", + FlatXmlOptions.DBUNIT_DEFAULTS, Map.of()); + + final DatasetTable users = model.findTable("USERS").orElseThrow(); + assertThat(users.getColumns()).as("The lower-case attribute must not add a column.") + .extracting(DatasetColumn::name).containsExactly("EMPNO"); + assertThat(users.getRows().get(0).getValue(0)) + .as("The declared column must take the lower-case attribute's value.").isEqualTo("1"); + } + + @Test + void testBuild_whenAPendingColumnHasNoData_isMarkedPending() + { + final DatasetModel model = buildWithoutDtd("", + FlatXmlOptions.DBUNIT_DEFAULTS, Map.of("USERS", List.of("EMAIL"))); + + final DatasetTable users = model.findTable("USERS").orElseThrow(); + assertThat(users.getColumns()).as("The pending column must be appended.") + .extracting(DatasetColumn::name).containsExactly("ID", "EMAIL"); + assertThat(users.getColumns().get(1).pending()).as("EMAIL must be pending.").isTrue(); + } + + @Test + void testBuild_whenDataProvidesAValueForAFormerlyPendingColumn_isNoLongerPending() + { + final DatasetModel model = + buildWithoutDtd("", + FlatXmlOptions.DBUNIT_DEFAULTS, Map.of("USERS", List.of("EMAIL"))); + + final DatasetTable users = model.findTable("USERS").orElseThrow(); + assertThat(users.getColumns().get(1).pending()) + .as("A pending column must stop being pending once data has it.").isFalse(); + } + + @Test + void testBuild_whenAColumnHasAtLeastOneValue_hasValuesIsTrue() + { + final DatasetModel model = buildWithoutDtd( + "", + FlatXmlOptions.DBUNIT_DEFAULTS, Map.of()); + + final DatasetTable users = model.findTable("USERS").orElseThrow(); + assertThat(users.getColumns().get(1).hasValues()) + .as("NAME has a value in the first row, so hasValues must be true.").isTrue(); + } + + @Test + void testBuild_whenNoRowHasAValueForAColumn_hasValuesIsFalse() + { + final DatasetModel model = buildWithDoctype( + "\n\n" + + "\n]>\n" + + "", + FlatXmlOptions.DBUNIT_DEFAULTS, Map.of()); + + final DatasetTable users = model.findTable("USERS").orElseThrow(); + assertThat(users.getColumns().get(1).hasValues()) + .as("NAME has no value in any row, so hasValues must be false.").isFalse(); + } + + @Test + void testBuild_whenDtdColumnsHaveDefaultAndFixedValues_theModelColumnsCarryThem() + { + final DatasetModel model = buildWithDoctype( + dtdDefaultsDoctype() + "", FlatXmlOptions.DBUNIT_DEFAULTS, + Map.of()); + + final DatasetTable users = model.findTable("USERS").orElseThrow(); + assertThat(users.getColumns()) + .as("Only the columns the DTD gives a default or fixed value carry one.") + .containsExactly(new DatasetColumn("ID", true, true, false), + new DatasetColumn("STATUS", true, false, false, "ACTIVE"), + new DatasetColumn("KIND", true, false, false, "A")); + } + + @Test + void testBuild_whenARowOmitsDefaultedAttributes_keepsNullAndLoadsTheDefaults() + { + final DatasetModel model = buildWithDoctype( + dtdDefaultsDoctype() + "", FlatXmlOptions.DBUNIT_DEFAULTS, + Map.of()); + + final DatasetTable users = model.findTable("USERS").orElseThrow(); + assertThat(users.getRows().get(0).getValues()) + .as("The row must keep no value where it has no attribute.") + .containsExactly("1", null, null); + assertThat(effectiveValues(users, 0)) + .as("dbUnit loads the defaults for the attributes the row omits.") + .containsExactly("1", "ACTIVE", "A"); + } + + @Test + void testBuild_whenARowSpecifiesDefaultedAttributes_theirValuesWinEvenWhenEmpty() + { + final DatasetModel model = buildWithDoctype( + dtdDefaultsDoctype() + "" + + "", + FlatXmlOptions.DBUNIT_DEFAULTS, Map.of()); + + final DatasetTable users = model.findTable("USERS").orElseThrow(); + assertThat(effectiveValues(users, 0)) + .as("A specified value must win over a default or a fixed value.") + .containsExactly("1", "x", "B"); + assertThat(effectiveValues(users, 1)).as("An empty specified value must win over the default.") + .containsExactly("2", "", "A"); + } + + @Test + void testBuild_whenADeclaredOnlyTableHasDefaults_itsColumnsCarryThem() + { + final DatasetModel model = buildWithDoctype(dtdDefaultsDoctype() + "", + FlatXmlOptions.DBUNIT_DEFAULTS, Map.of()); + + final DatasetTable users = model.findTable("USERS").orElseThrow(); + assertThat(users.getColumns()) + .as("A table that only the DTD declares must still show its columns' defaults.") + .extracting(DatasetColumn::defaultValue).containsExactly(null, "ACTIVE", "A"); + } + + @Test + void testBuild_whenAnElementWithoutAttributesHasDefaultValues_isARowOfDefaults() + { + final FlatXmlModelBuilder.Result result = buildResultWithDoctype( + dtdDefaultsDoctype() + "", FlatXmlOptions.DBUNIT_DEFAULTS); + + final DatasetTable users = result.model().findTable("USERS").orElseThrow(); + assertThat(users.getRows()).as("dbUnit loads the empty element as a row of default values.") + .hasSize(1); + assertThat(effectiveValues(users, 0)).as("The row must show what dbUnit loads for it.") + .containsExactly(null, "ACTIVE", "A"); + assertThat(result.index().getMarkerElements("USERS")) + .as("The element must be indexed as a row, so that edits treat it as one.").isEmpty(); + assertThat(result.index().getRowElements("USERS")).as("The element must be the row's element.") + .hasSize(1); + } + + @Test + void testBuild_whenAnElementWithoutAttributesHasNoDefaultValues_isStillAMarker() + { + final FlatXmlModelBuilder.Result result = buildResultWithDoctype( + "\n\n" + + "\n]>\n" + + "", + FlatXmlOptions.DBUNIT_DEFAULTS); + + assertThat(result.model().findTable("USERS").orElseThrow().getRows()) + .as("Without a default, dbUnit loads no row for the empty element.").isEmpty(); + assertThat(result.index().getMarkerElements("USERS")).as("The element must stay a marker.") + .hasSize(1); + } + + @Test + void testBuild_whenParseIsNotWellFormed_givesANonEditableModel() + { + final FlatXmlParseResult parse = FlatXmlParser.parse(""); + + final DatasetModel model = FlatXmlModelBuilder + .build("", parse, null, FlatXmlOptions.DBUNIT_DEFAULTS, Map.of()).model(); + + assertThat(model.isEditable()).as("A not well-formed parse must give a non-editable model.") + .isFalse(); + } + + private static DatasetModel buildWithoutDtd(final String text, final FlatXmlOptions options, + final Map> pendingColumns) + { + final FlatXmlParseResult parse = FlatXmlParser.parse(text); + return FlatXmlModelBuilder.build(text, parse, null, options, pendingColumns).model(); + } + + private static DatasetModel buildWithDoctype(final String text, final FlatXmlOptions options, + final Map> pendingColumns) + { + final FlatXmlParseResult parse = FlatXmlParser.parse(text); + final DtdDeclarations dtd = DtdReader.read(parse.doctype().internalSubset()); + return FlatXmlModelBuilder.build(text, parse, dtd, options, pendingColumns).model(); + } + + /** + * Builds the model of a text whose DOCTYPE has an internal subset and names an external DTD, merging + * the declarations as the dataset document does. + */ + private static DatasetModel buildWithExternalDtd(final String text, final String externalDtdText, + final FlatXmlOptions options) + { + final FlatXmlParseResult parse = FlatXmlParser.parse(text); + final DtdDeclarations internalSubset = DtdReader.read(parse.doctype().internalSubset()); + final DtdDeclarations dtd = internalSubset.merge(DtdReader.read(externalDtdText)); + return FlatXmlModelBuilder.build(text, parse, dtd, options, Map.of()).model(); + } + + private static FlatXmlModelBuilder.Result buildResultWithDoctype(final String text, + final FlatXmlOptions options) + { + final FlatXmlParseResult parse = FlatXmlParser.parse(text); + final DtdDeclarations dtd = DtdReader.read(parse.doctype().internalSubset()); + return FlatXmlModelBuilder.build(text, parse, dtd, options, Map.of()); + } + + /** + * Returns a DOCTYPE whose DTD declares USERS with a column without a default, a column with a default, + * and a column with a fixed value. + */ + private static String dtdDefaultsDoctype() + { + return "\n\n" + + "\n]>\n"; + } + + private static List effectiveValues(final DatasetTable table, final int rowIndex) + { + final List values = new ArrayList<>(); + for (int columnIndex = 0; columnIndex < table.getColumns().size(); columnIndex++) + { + values.add(table.getEffectiveValue(rowIndex, columnIndex)); + } + return values; + } + + @ParameterizedTest + @ValueSource(strings = { "flatXmlDataSetTest.xml", "flatXmlDataSetDuplicateTest.xml", + "flatXmlDataSetDuplicateMultipleCaseTest.xml", "editor-sample.xml", "column-sensing.xml", + "special-characters.xml", "column-name-case.xml" }) + void testBuild_whenFixtureHasNoDoctype_matchesDbUnitOnEveryColumn(final String fixtureName) + throws Exception + { + final File file = DbUnitParity.datasetsFile(fixtureName); + + DbUnitParity.assertParity(TestDatasets.read(fixtureName), file, true, true); + } + + @ParameterizedTest + @ValueSource(strings = { "flatXmlTableTest.xml", "flatXmlDataSetDtdDifferentCaseTest.xml", + "internal-subset.xml", "column-name-case-dtd.xml", "dtd-defaults-internal.xml", + "dtd-defaults-external.xml", "dtd-parameter-entity.xml", "doctype-root-name.xml", + "content-model-both.xml" }) + void testBuild_whenFixtureHasADoctype_matchesDbUnitOnDeclaredColumns(final String fixtureName) + throws Exception + { + final File file = DbUnitParity.datasetsFile(fixtureName); + + DbUnitParity.assertParity(TestDatasets.read(fixtureName), file, false, false); + } + + @Test + void testBuild_whenTheIso88591FixtureIsRead_matchesDbUnitOnEveryColumn() throws Exception + { + final String text = TestDatasets.read("iso-8859-1.xml", StandardCharsets.ISO_8859_1); + + DbUnitParity.assertParity(text, DbUnitParity.datasetsFile("iso-8859-1.xml"), true, true); + } + + @Test + void testBuild_whenEditorSampleIsRewrittenWithCrLf_matchesDbUnitOnEveryColumn( + @TempDir final Path tempDir) throws Exception + { + final String crlfText = TestDatasets.read("editor-sample.xml").replace("\n", "\r\n"); + final Path crlfFile = tempDir.resolve("crlf.xml"); + Files.writeString(crlfFile, crlfText, StandardCharsets.UTF_8); + + DbUnitParity.assertParity(crlfText, crlfFile.toFile(), true, true); + } + + @Test + void testDbUnit_whenTheFirstRowLacksAColumnTheOtherRowsHave_ignoresTheColumnWithoutColumnSensing( + @TempDir final Path tempDir) throws Exception + { + final Path file = tempDir.resolve("sparse-first-row.xml"); + Files.writeString(file, "\n \n \n\n", + StandardCharsets.UTF_8); + + final ITable withoutSensing = + new FlatXmlDataSetBuilder().setColumnSensing(false).build(file.toFile()).getTable("T"); + final ITable withSensing = + new FlatXmlDataSetBuilder().setColumnSensing(true).build(file.toFile()).getTable("T"); + + assertThat(List.of(DbUnitParity.columnNames(withoutSensing), DbUnitParity.columnNames(withSensing))) + .as("Without column sensing dbUnit takes the columns from the first row, which is why the " + + "editor keeps every column of the old first row on a new first row.") + .containsExactly(List.of("ID"), List.of("ID", "NAME")); + } + + @Test + void testBuild_whenInternalSubsetIsLoadedWithColumnSensing_dbUnitThrowsNoSuchColumnException() + { + assertThatThrownBy(() -> new FlatXmlDataSetBuilder().setColumnSensing(true) + .build(DbUnitParity.datasetsFile("internal-subset.xml"))).as( + "An attribute the DTD does not declare, loaded with column sensing, must fail, " + + "the case the validator reports as an ERROR.") + .isInstanceOf(NoSuchColumnException.class); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlOptionsTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlOptionsTest.java new file mode 100644 index 00000000..7dadde2b --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlOptionsTest.java @@ -0,0 +1,64 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link FlatXmlOptions}: the key that the options give a table. + */ +class FlatXmlOptionsTest +{ + @Test + void testTableKey_whenTableNamesAreCaseInsensitive_returnsTheNameInUpperCase() + { + final FlatXmlOptions options = new FlatXmlOptions(false, false); + + assertThat(options.tableKey("Users")).as("dbUnit's own defaults key a table by its upper-cased name.") + .isEqualTo("USERS"); + } + + @Test + void testTableKey_whenTableNamesAreCaseSensitive_returnsTheNameAsItIs() + { + final FlatXmlOptions options = new FlatXmlOptions(true, false); + + assertThat(options.tableKey("Users")).as("A case-sensitive name is its own key.").isEqualTo("Users"); + } + + @Test + void testTableKey_whenOnlyColumnSensingIsOn_stillUpperCasesTheName() + { + final FlatXmlOptions options = new FlatXmlOptions(false, true); + + assertThat(options.tableKey("Users")).as("Column sensing has nothing to do with the key of a table.") + .isEqualTo("USERS"); + } + + @Test + void testTableKey_forTheDefaults_isCaseInsensitive() + { + assertThat(FlatXmlOptions.DBUNIT_DEFAULTS.tableKey("users")).as("The defaults ignore letter case.") + .isEqualTo("USERS"); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlParserTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlParserTest.java new file mode 100644 index 00000000..4055c352 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlParserTest.java @@ -0,0 +1,875 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +import org.dbunit.dataset.DataSetException; +import org.dbunit.dataset.IDataSet; +import org.dbunit.dataset.xml.FlatXmlDataSetBuilder; +import org.dbunit.eclipse.dataset.core.TestDatasets; +import org.dbunit.eclipse.dataset.core.model.DatasetProblem; +import org.dbunit.eclipse.dataset.core.model.ProblemCode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.xml.sax.SAXParseException; + +/** + * Tests {@link FlatXmlParser} against the scanning rules of the flat XML specification, and against real + * dbUnit 3.5.2: for text content, comments, processing instructions, and CDATA sections, it compares + * whether the parser reports a dataset as not well-formed with whether dbUnit fails to load it, so that the + * editor neither lets the user edit a file that dbUnit cannot load nor refuses one that dbUnit loads. + */ +class FlatXmlParserTest +{ + private static final String CONTROL_CHARACTER = String.valueOf((char) 0x1); + + private static final String EMOJI = new String(Character.toChars(0x1F600)); + + private static final String NONCHARACTER_FFFE = String.valueOf((char) 0xFFFE); + + private static final String ARROW = String.valueOf((char) 0x2190); + + private static final String COMMA = String.valueOf((char) 0x3001); + + private static final String COMPATIBILITY_IDEOGRAPH = String.valueOf((char) 0xF900); + + private static final String COMBINING_GRAVE_ACCENT = String.valueOf((char) 0x300); + + private static final String COMBINING_LETTER_X = String.valueOf((char) 0x36F); + + private static final String THAI_LETTER = String.valueOf((char) 0xE01); + + private static final String IDEOGRAPH = String.valueOf((char) 0x4E00); + + private static final String MIDDLE_DOT = String.valueOf((char) 0xB7); + + private static final String XML_DECLARATION = "\n"; + + private static final String EXTERNAL_DOCTYPE = + XML_DECLARATION + "\n"; + + private static final String INTERNAL_SUBSET_START = XML_DECLARATION + "\n\n\n"; + + private static Stream textsWithAControlCharacter() + { + return Stream.of( + Arguments.of("body text", "\n a" + CONTROL_CHARACTER + "b\n"), + Arguments.of("row text", + "\n a" + CONTROL_CHARACTER + "b\n"), + Arguments.of("body comment", "\n \n"), + Arguments.of("row comment", "\n \n"), + Arguments.of("body processing instruction", + "\n \n"), + Arguments.of("body CDATA section", + "\n \n"), + Arguments.of("prolog comment", "\n"), + Arguments.of("prolog processing instruction", + "\n"), + Arguments.of("epilog comment", "\n"), + Arguments.of("internal subset comment", + "\n]>\n")); + } + + @Test + void testParse_whenTextIsEmpty_reportsRootNotDataset() + { + final FlatXmlParseResult result = FlatXmlParser.parse(""); + + assertThat(result.wellFormed()).as("An empty document is not well-formed.").isFalse(); + assertThat(result.problems()).as("An empty document must report ROOT_NOT_DATASET.") + .extracting(DatasetProblem::code).containsExactly(ProblemCode.ROOT_NOT_DATASET); + } + + @Test + void testParse_whenSelfClosingDataset_parsesToNoElements() + { + final FlatXmlParseResult result = FlatXmlParser.parse(""); + + assertThat(result.wellFormed()).as("A self-closing must be well-formed.").isTrue(); + assertThat(result.elements()).as("A self-closing has no row elements.").isEmpty(); + } + + @Test + void testParse_whenEmptyDataset_parsesToNoElements() + { + final FlatXmlParseResult result = FlatXmlParser.parse(""); + + assertThat(result.wellFormed()).as(" must be well-formed.").isTrue(); + assertThat(result.elements()).as(" has no row elements.").isEmpty(); + } + + @Test + void testParse_whenAttributesUseBothQuoteStyles_recordsExactOffsets() + { + final String text = ""; + + final FlatXmlParseResult result = FlatXmlParser.parse(text); + + assertThat(result.wellFormed()).as("The document must be well-formed.").isTrue(); + assertThat(result.elements()).as("There must be exactly one row element.").hasSize(1); + final FlatXmlElement element = result.elements().get(0); + assertThat(text.substring(element.offset(), element.nameEndOffset())) + .as("The element offsets must start at '"; + + final FlatXmlParseResult result = FlatXmlParser.parse(text); + + final FlatXmlAttribute first = result.elements().get(0).attributes().get(0); + final FlatXmlAttribute second = result.elements().get(1).attributes().get(0); + final FlatXmlAttribute other = result.elements().get(1).attributes().get(1); + assertThat(List.of(first.key(), other.key())).as("The key must be the name in upper case.") + .containsExactly("NAME", "OTHER"); + assertThat(second.key()) + .as("Attributes of one name must share one key, which the parser works out only once.") + .isSameAs(first.key()); + } + + @Test + void testParse_whenStartTagSpansSeveralLines_attributeSegmentsCoverExactlyTheAttributesRange() + { + final String text = + ""; + + final FlatXmlParseResult result = FlatXmlParser.parse(text); + + assertThat(result.wellFormed()).as("The document must be well-formed.").isTrue(); + final FlatXmlElement element = result.elements().get(0); + final StringBuilder concatenated = new StringBuilder(); + for (final FlatXmlAttribute attribute : element.attributes()) + { + concatenated.append(text, attribute.segmentOffset(), attribute.endOffset()); + } + final String expected = text.substring(element.nameEndOffset(), element.attributesEndOffset()); + assertThat(concatenated.toString()) + .as("The concatenated attribute segments must equal the attributes range verbatim.") + .isEqualTo(expected); + } + + @Test + void testParse_whenElementHasAnEndTag_recordsEndTagOffsetAndEndOffset() + { + final String text = ""; + + final FlatXmlParseResult result = FlatXmlParser.parse(text); + + final FlatXmlElement element = result.elements().get(0); + assertThat(element.selfClosing()).as("An element with an end tag is not self-closing.").isFalse(); + assertThat(text.substring(element.endTagOffset(), element.endOffset())) + .as("endTagOffset and endOffset must bound the end tag exactly.").isEqualTo(""); + } + + @Test + void testParse_whenElementsRepeatNames_sharesOneStringPerName() + { + final FlatXmlParseResult result = + FlatXmlParser.parse(""); + + final FlatXmlElement first = result.elements().get(0); + final FlatXmlElement second = result.elements().get(1); + assertThat(second.name()).as("Elements with the same name must share one name string.") + .isSameAs(first.name()); + assertThat(second.attributes().get(0).name()) + .as("Attributes with the same name must share one name string.") + .isSameAs(first.attributes().get(0).name()); + } + + @Test + void testParse_whenElementHasNoAttributes_hasEmptyAttributesList() + { + final FlatXmlParseResult result = FlatXmlParser.parse(""); + + assertThat(result.elements().get(0).attributes()).as("An element without attributes has none.") + .isEmpty(); + } + + @Test + void testParse_whenCommentsAndProcessingInstructionsAppearEverywhere_areSkipped() + { + final String text = "\n" + + "" + + ""; + + final FlatXmlParseResult result = FlatXmlParser.parse(text); + + assertThat(result.wellFormed()) + .as("Comments and processing instructions must not affect well-formedness.").isTrue(); + assertThat(result.elements()).as("Only the one row element must be recorded.").hasSize(1); + } + + @Test + void testParse_whenDoctypeHasSystemIdentifier_isRecorded() + { + final String text = ""; + + final FlatXmlParseResult result = FlatXmlParser.parse(text); + + assertThat(result.wellFormed()).as("A DOCTYPE with SYSTEM must be well-formed.").isTrue(); + assertThat(result.doctype().rootName()).as("The root name must be recorded.").isEqualTo("dataset"); + assertThat(result.doctype().systemId()).as("The system identifier must be recorded.") + .isEqualTo("my.dtd"); + assertThat(result.doctype().publicId()).as("There is no public identifier.").isNull(); + assertThat(result.doctype().internalSubset()).as("There is no internal subset.").isNull(); + } + + @Test + void testParse_whenDoctypeHasPublicIdentifier_isRecorded() + { + final String text = ""; + + final FlatXmlParseResult result = FlatXmlParser.parse(text); + + assertThat(result.wellFormed()).as("A DOCTYPE with PUBLIC must be well-formed.").isTrue(); + assertThat(result.doctype().publicId()).as("The public identifier must be recorded.") + .isEqualTo("-//pub//id"); + assertThat(result.doctype().systemId()).as("The system identifier must be recorded.") + .isEqualTo("my.dtd"); + } + + @Test + void testParse_whenInternalSubsetContainsGreaterThanAndBracketInsideQuotesAndComments_isRecordedWhole() + { + final String subset = + "\nc\">"; + final String text = ""; + + final FlatXmlParseResult result = FlatXmlParser.parse(text); + + assertThat(result.wellFormed()).as("The document must be well-formed.").isTrue(); + assertThat(result.doctype().internalSubset()).as( + "The internal subset text must be recorded exactly, including '>' and ']' inside quotes " + + "and comments.").isEqualTo(subset); + } + + @Test + void testParse_whenTextStartsWithByteOrderMark_isSkipped() + { + final String text = ""; + + final FlatXmlParseResult result = FlatXmlParser.parse(text); + + assertThat(result.wellFormed()).as("A leading BOM must not affect well-formedness.").isTrue(); + assertThat(result.root().offset()).as("The root offset must skip the BOM.").isEqualTo(1); + } + + @Test + void testParse_whenLineEndsAreCrLf_offsetsStayExact() + { + final String text = "\r\n \r\n"; + + final FlatXmlParseResult result = FlatXmlParser.parse(text); + + assertThat(result.wellFormed()).as("CR LF line ends must not affect well-formedness.").isTrue(); + final FlatXmlElement element = result.elements().get(0); + assertThat(text.substring(element.offset(), element.endOffset())) + .as("The element offsets must point exactly at the element text.") + .isEqualTo(""); + } + + @Test + void testParse_whenEditorSampleIsRewrittenWithCrLf_stillHasNoBlockingProblems() + { + final String lf = TestDatasets.read("editor-sample.xml"); + final String crlf = lf.replace("\n", "\r\n"); + + final FlatXmlParseResult result = FlatXmlParser.parse(crlf); + + assertThat(result.wellFormed()) + .as("A CR LF variant of editor-sample.xml must still be well-formed.").isTrue(); + } + + @Test + void testParse_whenBodyHasText_reportsTextContentIgnoredAndContinues() + { + final String text = "stray text"; + + final FlatXmlParseResult result = FlatXmlParser.parse(text); + + assertThat(result.wellFormed()).as("Stray text in the body must not block editing.").isTrue(); + assertThat(result.elements()).as("Parsing must continue and still find the row element.") + .hasSize(1); + assertThat(result.problems()).as("Exactly one TEXT_CONTENT_IGNORED problem must be reported.") + .extracting(DatasetProblem::code).containsExactly(ProblemCode.TEXT_CONTENT_IGNORED); + final DatasetProblem problem = result.problems().get(0); + assertThat(text.substring(problem.offset(), problem.offset() + problem.length())) + .as("The problem range must cover exactly the stray text.").isEqualTo("stray text"); + } + + @Test + void testParse_whenRootIsNotDataset_reportsRootNotDatasetAtRootOffset() + { + final String text = TestDatasets.read("malformed/root-not-dataset.xml"); + + assertBlockingProblem(text, ProblemCode.ROOT_NOT_DATASET, text.indexOf("\n\u3000"; + + assertBlockingProblem(text, ProblemCode.NOT_WELL_FORMED, text.indexOf('\u3000')); + } + + @Test + void testParse_whenEndTagNameDoesNotMatch_reportsNotWellFormedAtEndTagOffset() + { + final String text = TestDatasets.read("malformed/mismatched-end-tag.xml"); + + assertBlockingProblem(text, ProblemCode.NOT_WELL_FORMED, text.indexOf("\n"); + + assertThat(result.problems()).extracting(DatasetProblem::message) + .as("The message must name the expected and the found element, and where the end tag is.") + .containsExactly("Expected , but found . (line 2, column 8)"); + } + + @Test + void testParse_whenAttributeIsDuplicated_reportsNotWellFormedAtSecondAttributeNameOffset() + { + final String text = TestDatasets.read("malformed/duplicate-attribute.xml"); + + assertBlockingProblem(text, ProblemCode.NOT_WELL_FORMED, text.lastIndexOf("ID")); + } + + @Test + void testParse_whenValueContainsLessThan_reportsNotWellFormedAtLessThanOffset() + { + final String text = TestDatasets.read("malformed/less-than-in-value.xml"); + + assertBlockingProblem(text, ProblemCode.NOT_WELL_FORMED, + text.indexOf('<', text.indexOf("NAME="))); + } + + @Test + void testParse_whenWhitespaceIsMissingBetweenAttributes_reportsNotWellFormedAtSecondAttributeOffset() + { + final String text = TestDatasets.read("malformed/missing-whitespace-between-attributes.xml"); + + assertBlockingProblem(text, ProblemCode.NOT_WELL_FORMED, text.indexOf("NAME=")); + } + + @Test + void testParse_whenEntityIsUnknown_reportsUnsupportedEntityAtAmpersandOffset() + { + final String text = TestDatasets.read("malformed/unknown-entity.xml"); + + assertBlockingProblem(text, ProblemCode.UNSUPPORTED_ENTITY, text.indexOf('&')); + } + + @Test + void testParse_whenBodyTextHasABareAmpersand_reportsNotWellFormedAtTheAmpersand() + { + final String text = TestDatasets.read("malformed/bare-ampersand-in-text.xml"); + + assertBlockingProblem(text, ProblemCode.NOT_WELL_FORMED, text.indexOf('&')); + } + + @Test + void testParse_whenARowHoldsAnUndeclaredEntityInItsText_reportsNotWellFormedAtTheAmpersand() + { + final String text = TestDatasets.read("malformed/undeclared-entity-in-text.xml"); + + assertBlockingProblem(text, ProblemCode.NOT_WELL_FORMED, text.indexOf('&')); + } + + @Test + void testParse_whenBodyTextHoldsACdataEnd_reportsNotWellFormedAtIt() + { + final String text = "\n a ]]> b\n \n"; + + assertBlockingProblem(text, ProblemCode.NOT_WELL_FORMED, text.indexOf("]]>")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("textsWithAControlCharacter") + void testParse_whenAControlCharacterIsInTextOrMarkup_reportsNotWellFormedAtIt(final String place, + final String text) + { + assertBlockingProblem(text, ProblemCode.NOT_WELL_FORMED, text.indexOf(CONTROL_CHARACTER)); + } + + @Test + void testParse_whenTheDoctypeHasAnExternalSubset_acceptsAnEntityReferenceInText() + { + final String text = "\n\n  \n" + + " \n"; + + final FlatXmlParseResult result = FlatXmlParser.parse(text); + + assertThat(result.wellFormed()).as("The external DTD may declare the entity.").isTrue(); + assertThat(result.problems()).as("The text is still reported as ignored.") + .extracting(DatasetProblem::code).containsExactly(ProblemCode.TEXT_CONTENT_IGNORED); + } + + @Test + void testParse_whenTheInternalSubsetDeclaresAnEntity_acceptsAnEntityReferenceInText() + { + final String text = "\n]>\n\n  \n" + + " \n"; + + final FlatXmlParseResult result = FlatXmlParser.parse(text); + + assertThat(result.wellFormed()).as("The internal subset declares the entity.").isTrue(); + } + + @Test + void testParse_whenTheInternalSubsetDeclaresNoEntity_reportsAnEntityReferenceInText() + { + final String text = "\n]>\n\n  \n" + + " \n"; + + assertBlockingProblem(text, ProblemCode.NOT_WELL_FORMED, text.indexOf('&')); + } + + @Test + void testParse_whenTheDoctypeDeclaresNothing_reportsAnEntityReferenceInText() + { + final String text = "\n\n  \n \n"; + + assertBlockingProblem(text, ProblemCode.NOT_WELL_FORMED, text.indexOf('&')); + } + + @Test + void testParse_whenTheDoctypeHasAnExternalSubset_reportsABareAmpersandInText() + { + final String text = "\n\n R&D\n" + + " \n"; + + assertBlockingProblem(text, ProblemCode.NOT_WELL_FORMED, text.indexOf('&')); + } + + @Test + void testParse_whenTextAndMarkupHoldContentThatLooksOddButIsAllowed_staysWellFormed() + { + final String text = "\n" + + " & < > " ' A A ]] > a > b\n" + + " \n" + + " " + EMOJI + "?>\n" + + " \n" + + " b\">" + EMOJI + (char) 0x7F + (char) 0xFDD0 + "\n" + + ""; + + final FlatXmlParseResult result = FlatXmlParser.parse(text); + + assertThat(result.wellFormed()).as("Everything in this text is allowed by XML 1.0.").isTrue(); + assertThat(result.elements()).as("The row element must still be found.").hasSize(1); + } + + @Test + void testParse_whenContentFollowsTheRoot_reportsNotWellFormedAtThatOffset() + { + final String text = TestDatasets.read("malformed/content-after-root.xml"); + + assertBlockingProblem(text, ProblemCode.NOT_WELL_FORMED, text.indexOf(" fixtures = List.of("flatXmlDataSetTest.xml", "flatXmlDataSetDuplicateTest.xml", + "flatXmlDataSetDuplicateMultipleCaseTest.xml", "flatXmlTableTest.xml", + "flatXmlDataSetDtdDifferentCaseTest.xml", "editor-sample.xml", "column-sensing.xml", + "first-element-empty.xml", "internal-subset.xml", "special-characters.xml"); + for (final String fixture : fixtures) + { + final FlatXmlParseResult result = FlatXmlParser.parse(TestDatasets.read(fixture)); + + assertThat(result.wellFormed()).as( + "Fixture '" + fixture + "' must parse without a problem that blocks editing.") + .isTrue(); + } + } + + @Test + void testParse_whenGivenTheIso88591Fixture_hasNoBlockingProblems() + { + final String text = TestDatasets.read("iso-8859-1.xml", StandardCharsets.ISO_8859_1); + + final FlatXmlParseResult result = FlatXmlParser.parse(text); + + assertThat(result.wellFormed()) + .as("The ISO-8859-1 fixture must parse without a problem that blocks editing.").isTrue(); + } + + @Test + void testParse_whenParsingLargeDatasets_charAtCallCountStaysLinear() + { + final String smallText = generateDataset(2000); + final String largeText = generateDataset(4000); + + final CountingCharSequence smallCounted = new CountingCharSequence(smallText); + FlatXmlParser.parse(smallCounted); + final long smallCount = smallCounted.getCharAtCallCount(); + + final CountingCharSequence largeCounted = new CountingCharSequence(largeText); + FlatXmlParser.parse(largeCounted); + final long largeCount = largeCounted.getCharAtCallCount(); + + assertThat(smallCount).as("The charAt call count must not exceed 4 times the text length.") + .isLessThanOrEqualTo(smallText.length() * 4L); + assertThat(largeCount) + .as("Doubling the rows must at most double the charAt call count (plus 1%).") + .isLessThanOrEqualTo((long) (smallCount * 2.01)); + } + + private void assertBlockingProblem(final String text, final ProblemCode expectedCode, + final int expectedOffset) + { + final FlatXmlParseResult result = FlatXmlParser.parse(text); + + assertThat(result.wellFormed()).as("A document with this problem must not be well-formed.") + .isFalse(); + assertThat(result.problems()).as("The blocking problem must be reported last.").last() + .satisfies(problem -> + { + assertThat(problem.code()).as("The problem code must match.").isEqualTo(expectedCode); + assertThat(problem.offset()).as("The problem offset must match.") + .isEqualTo(expectedOffset); + }); + } + + private static String generateDataset(final int rowCount) + { + // A fixed-width id keeps every row the same length, so the text length (and so the expected + // charAt count) scales exactly with rowCount instead of drifting with the id's digit count. + final StringBuilder text = new StringBuilder(); + text.append("\n"); + for (int i = 0; i < rowCount; i++) + { + final String id = String.format("%07d", i); + text.append(" \n"); + } + text.append("\n"); + return text.toString(); + } + + private static final class CountingCharSequence implements CharSequence + { + private final String delegate; + + private long charAtCallCount; + + private CountingCharSequence(final String delegate) + { + this.delegate = delegate; + } + + private long getCharAtCallCount() + { + return charAtCallCount; + } + + @Override + public int length() + { + return delegate.length(); + } + + @Override + public char charAt(final int index) + { + charAtCallCount++; + return delegate.charAt(index); + } + + @Override + public CharSequence subSequence(final int start, final int end) + { + return delegate.subSequence(start, end); + } + + @Override + public String toString() + { + return delegate; + } + } + + private static String body(final String content) + { + return "\n " + content + "\n \n\n"; + } + + private static String rowOf(final String tableName, final String columnName) + { + return "\n <" + tableName + " " + columnName + "=\"1\"/>\n\n"; + } + + private static String rowText(final String content) + { + return "\n " + content + "\n\n"; + } + + private static Stream malformedTexts() + { + return Stream.of( + Arguments.of("a bare ampersand in body text", body("R&D fixtures")), + Arguments.of("an ampersand and a space", body("R & D")), + Arguments.of("an ampersand and a semicolon", body("&;")), + Arguments.of("a reference without its semicolon", body("& more")), + Arguments.of("a character reference without digits", body("&#;")), + Arguments.of("a hexadecimal character reference without digits", body("&#x;")), + Arguments.of("a character reference with a letter", body(" a;")), + Arguments.of("a character reference with an uppercase X", body("A")), + Arguments.of("a reference to a control character", body("")), + Arguments.of("a reference to a surrogate", body("�")), + Arguments.of("a reference to U+FFFE", body("￾")), + Arguments.of("a reference beyond U+10FFFF", body("�")), + Arguments.of("an undeclared entity in body text", body(" ")), + Arguments.of("an undeclared entity in row text", rowText(" ")), + Arguments.of("an undeclared entity next to an internal subset without entities", + INTERNAL_SUBSET_START + "]>\n" + body(" ")), + Arguments.of("an undeclared entity in a DOCTYPE without a DTD", + XML_DECLARATION + "\n" + body(" ")), + Arguments.of("a CDATA end in body text", body("a ]]> b")), + Arguments.of("a CDATA end in row text", rowText("a ]]> b")), + Arguments.of("a CDATA end after a CDATA section", body("]]>")), + Arguments.of("a control character in body text", body("a" + CONTROL_CHARACTER + "b")), + Arguments.of("a control character in row text", rowText("a" + CONTROL_CHARACTER + "b")), + Arguments.of("U+FFFE in body text", body("a" + NONCHARACTER_FFFE + "b")), + Arguments.of("a control character in a comment", + body("")), + Arguments.of("U+FFFE in a comment", body("")), + Arguments.of("a control character in a processing instruction", + body("")), + Arguments.of("a control character in a CDATA section", + body("")), + Arguments.of("a control character in a comment before the root", + "\n" + body("")), + Arguments.of("a control character in a comment after the root", + body("") + "\n"), + Arguments.of("a control character in a processing instruction before the root", + "\n" + body("")), + Arguments.of("a bare ampersand next to an external DTD", EXTERNAL_DOCTYPE + body("R&D")), + Arguments.of("a CDATA end next to an external DTD", EXTERNAL_DOCTYPE + body("a ]]> b")), + Arguments.of("a control character next to an external DTD", + EXTERNAL_DOCTYPE + body("a" + CONTROL_CHARACTER + "b")), + Arguments.of("a reference to a control character next to an external DTD", + EXTERNAL_DOCTYPE + body("")), + Arguments.of("a double hyphen in a comment", body("")), + Arguments.of("a comment that ends with three hyphens", body("")), + Arguments.of("a double hyphen in a comment before the root", + "\n" + body("")), + Arguments.of("a double hyphen in a comment after the root", + body("") + "\n"), + Arguments.of("a double hyphen in a comment of the internal subset", + INTERNAL_SUBSET_START + "\n]>\n" + body("")), + Arguments.of("an XML declaration after white space", "\n" + XML_DECLARATION + body("")), + Arguments.of("an XML declaration after a comment", + "\n" + XML_DECLARATION + body("")), + Arguments.of("a second XML declaration", XML_DECLARATION + XML_DECLARATION + body("")), + Arguments.of("an XML declaration inside the root", body(XML_DECLARATION)), + Arguments.of("an XML declaration after the root", body("") + XML_DECLARATION), + Arguments.of("an XML declaration in the internal subset", + INTERNAL_SUBSET_START + XML_DECLARATION + "]>\n" + body("")), + Arguments.of("a processing instruction target in capitals", body("")), + Arguments.of("a processing instruction target in mixed case", body("")), + Arguments.of("an XML declaration in capitals", "\n" + body("")), + Arguments.of("a processing instruction without a target", body("")), + Arguments.of("a processing instruction target that does not start with a name character", + body("")), + Arguments.of("a processing instruction target followed by a quote", body("")), + Arguments.of("a table name that starts with an arrow", rowOf(ARROW + "T", "ID")), + Arguments.of("a column name with an arrow in it", rowOf("T", "A" + ARROW + "B")), + Arguments.of("a table name that starts with an ideographic comma", rowOf(COMMA + "T", "ID")), + Arguments.of("a column name that starts with a compatibility ideograph", + rowOf("T", COMPATIBILITY_IDEOGRAPH + "ID")), + Arguments.of("a column name with a supplementary character in it", + rowOf("T", "A" + EMOJI + "B")), + Arguments.of("a table name that is a supplementary character", rowOf(EMOJI, "ID")), + Arguments.of("a column name with a combining character that the fourth edition lacks", + rowOf("T", "A" + COMBINING_LETTER_X)), + Arguments.of("a SYSTEM keyword with no white space before its literal", + XML_DECLARATION + "\n" + body("")), + Arguments.of("a PUBLIC keyword with no white space before its literal", + XML_DECLARATION + "\n" + body("")), + Arguments.of("a public and a system literal with no white space between them", + XML_DECLARATION + "\n" + body(""))); + } + + private static Stream wellFormedTexts() + { + return Stream.of( + Arguments.of("the five predefined entities in body text", + body("& < > " '")), + Arguments.of("the five predefined entities in row text", + rowText("& < > " '")), + Arguments.of("character references in body text", + body("A A 😀 ﷐ …")), + Arguments.of("greater-than signs and close brackets in body text", body("a > b ]] > c ] d")), + Arguments.of("a CDATA end in a comment", body("")), + Arguments.of("a CDATA end in a processing instruction", body(" b?>")), + Arguments.of("a CDATA end in an attribute value", + "\n b\"/>\n\n"), + Arguments.of("a bare ampersand in a comment", body("")), + Arguments.of("a bare ampersand in a processing instruction", body("")), + Arguments.of("a bare ampersand in a CDATA section", body("")), + Arguments.of("an undeclared entity in a CDATA section", body("")), + Arguments.of("a bracket before the end of a CDATA section", body("")), + Arguments.of("supplementary characters in text, a comment, and a CDATA section", + body("a" + EMOJI + "b ")), + Arguments.of("DEL, NEL, and a noncharacter in body text", + body("a" + (char) 0x7F + (char) 0x85 + (char) 0xFDD0 + "b")), + Arguments.of("an undeclared entity next to a SYSTEM identifier", + EXTERNAL_DOCTYPE + body(" ")), + Arguments.of("an undeclared entity in row text next to a SYSTEM identifier", + EXTERNAL_DOCTYPE + rowText(" ")), + Arguments.of("an undeclared entity next to PUBLIC and SYSTEM identifiers", + XML_DECLARATION + "\n" + body(" ")), + Arguments.of("an entity that the external DTD declares", + XML_DECLARATION + "\n" + body(" ")), + Arguments.of("an entity that the internal subset declares", + INTERNAL_SUBSET_START + "\n]>\n" + body(" ")), + Arguments.of("an entity that the internal subset declares, in row text", + INTERNAL_SUBSET_START + "\n]>\n" + rowText(" ")), + Arguments.of("an XML declaration at the start", XML_DECLARATION + body("")), + Arguments.of("an XML declaration after a byte order mark", + "\uFEFF" + XML_DECLARATION + body("")), + Arguments.of("an XML declaration with an encoding and a standalone flag", + "\n" + body("")), + Arguments.of("a processing instruction whose target starts with xml", + body("")), + Arguments.of("a processing instruction before the root", "\n" + body("")), + Arguments.of("a processing instruction without data", body("")), + Arguments.of("a processing instruction target with hyphens, dots, and digits", + body("")), + Arguments.of("single hyphens in a comment", body("")), + Arguments.of("an empty comment", body("")), + Arguments.of("a comment with a hyphen before its end", body("")), + Arguments.of("a comment of hyphens and spaces", body("")), + Arguments.of("a comment in the internal subset", + INTERNAL_SUBSET_START + "\n]>\n" + body("")), + Arguments.of("a column name that starts with a Thai letter", + rowOf("USERS", THAI_LETTER + "ID")), + Arguments.of("a column name that starts with an ideograph", rowOf("USERS", IDEOGRAPH + "ID")), + Arguments.of("a column name with a middle dot and a combining accent in it", + rowOf("USERS", "A" + MIDDLE_DOT + "B" + COMBINING_GRAVE_ACCENT)), + Arguments.of("a column name with a hyphen, a dot, and digits in it", + rowOf("USERS", "A-1.2")), + Arguments.of("a DOCTYPE with line breaks and tabs between its parts", + XML_DECLARATION + "\n" + body("")), + Arguments.of("an internal subset that follows the literal with no white space", + XML_DECLARATION + "\n" + body(""))); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("malformedTexts") + void testParse_whenTheTextIsNotWellFormed_dbUnitFailsToLoadItAndTheParserBlocksEditing( + final String description, final String text, @TempDir final Path tempDir) throws Exception + { + final File file = write(tempDir, text); + + assertThatThrownBy(() -> new FlatXmlDataSetBuilder().build(file)) + .as("dbUnit must fail to load " + description + ", because its XML parser rejects it.") + .isInstanceOf(DataSetException.class).hasRootCauseInstanceOf(SAXParseException.class); + final FlatXmlParseResult result = FlatXmlParser.parse(text); + assertThat(result.wellFormed()) + .as("The parser must report " + description + " as not well-formed, as dbUnit does.") + .isFalse(); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("wellFormedTexts") + void testParse_whenTheTextIsWellFormed_dbUnitLoadsItAndTheParserAllowsEditing(final String description, + final String text, @TempDir final Path tempDir) throws Exception + { + final File file = write(tempDir, text); + + final IDataSet dataSet = new FlatXmlDataSetBuilder().build(file); + assertThat(dataSet.getTableNames()).as("dbUnit must load " + description + " as a dataset.") + .containsExactly("USERS"); + final FlatXmlParseResult result = FlatXmlParser.parse(text); + assertThat(result.wellFormed()) + .as("The parser must accept " + description + ", as dbUnit does.").isTrue(); + } + + /** + * Writes the DTD files that some of the texts name, and the text as the dataset file that dbUnit loads. + */ + private static File write(final Path directory, final String text) throws IOException + { + final String declarations = + "\n\n\n"; + Files.writeString(directory.resolve("plain.dtd"), declarations, StandardCharsets.UTF_8); + Files.writeString(directory.resolve("entity.dtd"), declarations + "\n", + StandardCharsets.UTF_8); + final Path file = directory.resolve("dataset.xml"); + Files.writeString(file, text, StandardCharsets.UTF_8); + return file.toFile(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlTextLayoutTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlTextLayoutTest.java new file mode 100644 index 00000000..5864af7c --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlTextLayoutTest.java @@ -0,0 +1,284 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.eclipse.jface.text.Document; +import org.eclipse.jface.text.IRegion; +import org.eclipse.text.edits.TextEdit; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link FlatXmlTextLayout} against the indentation and line-extent rules of the Edit Engine + * specification. + */ +class FlatXmlTextLayoutTest +{ + @Test + void testIndentOf_whenElementIsIndentedWithSpaces_returnsTheSpaces() + { + final String text = "\n \n\n"; + final FlatXmlElement element = onlyElement(text); + final FlatXmlTextLayout layout = new FlatXmlTextLayout(text, "\n"); + + assertThat(layout.indentOf(element)) + .as("The indentation must be the four spaces before the element.").isEqualTo(" "); + } + + @Test + void testIndentOf_whenElementIsIndentedWithATab_returnsTheTab() + { + final String text = "\n\t\n\n"; + final FlatXmlElement element = onlyElement(text); + final FlatXmlTextLayout layout = new FlatXmlTextLayout(text, "\n"); + + assertThat(layout.indentOf(element)).as("The indentation must be the tab before the element.") + .isEqualTo("\t"); + } + + @Test + void testIndentOf_whenIdeographicSpacesPrecedeTheElement_returnsEmpty() + { + final String text = "\n\u3000\u3000\n\n"; + final FlatXmlElement element = FlatXmlParser.parse(text).elements().get(0); + final FlatXmlTextLayout layout = new FlatXmlTextLayout(text, "\n"); + + assertThat(layout.indentOf(element)) + .as("The ideographic space is not white space to the XML parser, so it is not an " + + "indentation that new rows may copy.") + .isEmpty(); + } + + @Test + void testIndentOf_whenElementIsInline_returnsEmpty() + { + final String text = "\n"; + final FlatXmlElement element = onlyElement(text); + final FlatXmlTextLayout layout = new FlatXmlTextLayout(text, "\n"); + + assertThat(layout.indentOf(element)) + .as("Non-whitespace precedes the element on its line, so there is no indentation.") + .isEqualTo(""); + } + + @Test + void testChildIndentation_whenRootHasChildren_returnsTheFirstChildsIndentation() + { + final String text = "\n \n\n"; + final FlatXmlParseResult parse = FlatXmlParser.parse(text); + final FlatXmlTextLayout layout = new FlatXmlTextLayout(text, "\n"); + + final String indentation = layout.childIndentation(parse.root(), parse.elements()); + + assertThat(indentation).as("Without an explicit new child, indentation must match the first one.") + .isEqualTo(" "); + } + + @Test + void testChildIndentation_whenRootHasNoChildren_returnsTheRootsIndentationPlusTwoSpaces() + { + final String text = " \n\n"; + final FlatXmlParseResult parse = FlatXmlParser.parse(text); + final FlatXmlTextLayout layout = new FlatXmlTextLayout(text, "\n"); + + final String indentation = layout.childIndentation(parse.root(), parse.elements()); + + assertThat(indentation).as("Without children, the indentation must be the root's indentation " + + "plus two spaces.") + .isEqualTo(" "); + } + + @Test + void testLineExtent_whenElementIsAloneOnItsLine_returnsTheWholeLineWithItsDelimiter() + { + final String text = "\n \n\n"; + final FlatXmlElement element = onlyElement(text); + final FlatXmlTextLayout layout = new FlatXmlTextLayout(text, "\n"); + + final IRegion region = layout.lineExtent(element); + + assertThat(substringOf(text, region)).as("The whole line, including its delimiter, must be removed.") + .isEqualTo(" \n"); + } + + @Test + void testLineExtent_whenWhitespacePrecedesTheElementButSomethingFollowsIt_returnsTheElementAlone() + { + final String text = "\n \n\n"; + final FlatXmlElement element = FlatXmlParser.parse(text).elements().get(0); + final FlatXmlTextLayout layout = new FlatXmlTextLayout(text, "\n"); + + final IRegion region = layout.lineExtent(element); + + assertThat(substringOf(text, region)) + .as("An element that something follows on its line must be removed alone, not with the line.") + .isEqualTo(""); + } + + @Test + void testLineExtent_whenWhitespaceSurroundsTheElementOnItsLine_returnsTheWholeLine() + { + final String text = "\n \t \t\n\n"; + final FlatXmlElement element = FlatXmlParser.parse(text).elements().get(0); + final FlatXmlTextLayout layout = new FlatXmlTextLayout(text, "\n"); + + final IRegion region = layout.lineExtent(element); + + assertThat(substringOf(text, region)).as("The whitespace around the element belongs to its line.") + .isEqualTo(" \t \t\n"); + } + + @Test + void testLineExtent_whenAnIdeographicSpaceFollowsTheElement_returnsTheElementAlone() + { + final String text = "\n \u3000\n\n"; + final FlatXmlElement element = FlatXmlParser.parse(text).elements().get(0); + final FlatXmlTextLayout layout = new FlatXmlTextLayout(text, "\n"); + + final IRegion region = layout.lineExtent(element); + + assertThat(substringOf(text, region)) + .as("An ideographic space is something else on the line to the XML parser, so the line " + + "does not belong to the element.") + .isEqualTo(""); + } + + @Test + void testLineExtent_whenElementSharesItsLineWithOtherContent_returnsTheElementAlone() + { + final String text = "\n"; + final FlatXmlParseResult parse = FlatXmlParser.parse(text); + final FlatXmlElement element = parse.elements().get(0); + final FlatXmlTextLayout layout = new FlatXmlTextLayout(text, "\n"); + + final IRegion region = layout.lineExtent(element); + + assertThat(substringOf(text, region)) + .as("An inline element must be removed alone, keeping its neighbors.") + .isEqualTo(""); + } + + @Test + void testIndentOf_whenLineEndsAreCrLf_stillFindsTheLineStart() + { + final String text = "\r\n \r\n\r\n"; + final FlatXmlElement element = onlyElement(text); + final FlatXmlTextLayout layout = new FlatXmlTextLayout(text, "\r\n"); + + assertThat(layout.indentOf(element)).as("CR LF line ends must not affect indentation detection.") + .isEqualTo(" "); + } + + @Test + void testLineExtent_whenLineEndsAreCrLf_includesTheWholeDelimiter() + { + final String text = "\r\n \r\n\r\n"; + final FlatXmlElement element = onlyElement(text); + final FlatXmlTextLayout layout = new FlatXmlTextLayout(text, "\r\n"); + + final IRegion region = layout.lineExtent(element); + + assertThat(substringOf(text, region)) + .as("The CR LF delimiter must be included whole, not split.") + .isEqualTo(" \r\n"); + } + + @Test + void testInsertAsLastChildOfRoot_whenTheRootIsSelfClosing_opensAndClosesTheRootAroundTheText() + throws Exception + { + final String text = "\n"; + + final String result = insertAsLastChildOfRoot(text, "\n", ""); + + assertThat(result).as("The self-closing root must become an open and a close tag around the child.") + .isEqualTo("\n \n\n"); + } + + @Test + void testInsertAsLastChildOfRoot_whenTheEndTagIsAloneOnItsLine_insertsAtTheStartOfThatLine() + throws Exception + { + final String text = "\n \n\n"; + + final String result = insertAsLastChildOfRoot(text, "\n", ""); + + assertThat(result).as("The new child must get its own line with the indentation of its siblings.") + .isEqualTo("\n \n \n\n"); + } + + @Test + void testInsertAsLastChildOfRoot_whenTheEndTagFollowsContentOnItsLine_insertsOnNewLines() + throws Exception + { + final String text = ""; + + final String result = insertAsLastChildOfRoot(text, "\n", ""); + + assertThat(result).as("The child must go between two line breaks before the end tag.") + .isEqualTo("\n\n"); + } + + @Test + void testInsertAsLastChildOfRoot_whenTheRootHasNoChildren_indentsTheChildTwoSpacesDeeperThanTheRoot() + throws Exception + { + final String text = " \n \n"; + + final String result = insertAsLastChildOfRoot(text, "\n", ""); + + assertThat(result).as("Without siblings, the child is indented like the root plus two spaces.") + .isEqualTo(" \n \n \n"); + } + + @Test + void testInsertAsLastChildOfRoot_whenTheDelimiterIsCrLf_usesItForTheNewLines() throws Exception + { + final String text = "\r\n"; + + final String result = insertAsLastChildOfRoot(text, "\r\n", ""); + + assertThat(result).as("The new lines must end with the layout's delimiter.") + .isEqualTo("\r\n \r\n\r\n"); + } + + private static String insertAsLastChildOfRoot(final String text, final String delimiter, + final String childrenText) throws Exception + { + final FlatXmlParseResult parse = FlatXmlParser.parse(text); + final FlatXmlTextLayout layout = new FlatXmlTextLayout(text, delimiter); + final TextEdit edit = layout.insertAsLastChildOfRoot(parse.root(), parse.elements(), childrenText); + final Document document = new Document(text); + edit.apply(document); + return document.get(); + } + + private static String substringOf(final String text, final IRegion region) + { + return text.substring(region.getOffset(), region.getOffset() + region.getLength()); + } + + private static FlatXmlElement onlyElement(final String text) + { + return FlatXmlParser.parse(text).elements().get(0); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlValidatorTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlValidatorTest.java new file mode 100644 index 00000000..124262fa --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlValidatorTest.java @@ -0,0 +1,400 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.dbunit.eclipse.dataset.core.Messages; +import org.dbunit.eclipse.dataset.core.TestDatasets; +import org.dbunit.eclipse.dataset.core.dtd.DtdDeclarations; +import org.dbunit.eclipse.dataset.core.dtd.DtdReader; +import org.dbunit.eclipse.dataset.core.model.DatasetProblem; +import org.dbunit.eclipse.dataset.core.model.ProblemCode; +import org.dbunit.eclipse.dataset.core.model.ProblemSeverity; +import org.eclipse.osgi.util.NLS; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link FlatXmlValidator} against every code and location of the Validator section of the flat + * XML specification. + */ +class FlatXmlValidatorTest +{ + @Test + void testValidate_whenALaterRowHasAColumnMissingFromTheFirstElement_reportsColumnNotInFirstRow() + { + final List problems = validate( + "", + FlatXmlOptions.DBUNIT_DEFAULTS, false); + + assertThat(onlyCode(problems, ProblemCode.COLUMN_NOT_IN_FIRST_ROW)).as( + "A column missing from the first element but present later must be reported once.") + .hasSize(1); + } + + @Test + void testValidate_whenTheFirstLaterValueIsSpelledTwiceInItsRow_reportsColumnNotInFirstRowAtTheLast() + { + final String text = "" + + ""; + + final List problems = validate(text, FlatXmlOptions.DBUNIT_DEFAULTS, false); + + assertThat(onlyCode(problems, ProblemCode.COLUMN_NOT_IN_FIRST_ROW)) + .extracting(DatasetProblem::rowIndex, DatasetProblem::offset) + .as("The problem must point at the row's last attribute for the column, whose value dbUnit " + + "uses.") + .containsExactly(tuple(2, text.indexOf("NAME="))); + } + + @Test + void testValidate_whenEveryRowHasTheFirstElementsColumns_doesNotReportColumnNotInFirstRow() + { + final List problems = validate( + "", + FlatXmlOptions.DBUNIT_DEFAULTS, false); + + assertThat(onlyCode(problems, ProblemCode.COLUMN_NOT_IN_FIRST_ROW)) + .as("No column is missing from the first element.").isEmpty(); + } + + @Test + void testValidate_whenColumnSensingIsEnabled_suppressesColumnNotInFirstRow() + { + final List problems = + validate("", + new FlatXmlOptions(false, true), false); + + assertThat(onlyCode(problems, ProblemCode.COLUMN_NOT_IN_FIRST_ROW)) + .as("Column sensing must suppress COLUMN_NOT_IN_FIRST_ROW.").isEmpty(); + } + + @Test + void testValidate_whenADoctypeIsPresent_suppressesColumnNotInFirstRowAndFirstElementWithoutAttributes() + { + final List problems = validate( + "\n\n" + + "\n]>\n" + + "", + FlatXmlOptions.DBUNIT_DEFAULTS, false); + + assertThat(onlyCode(problems, ProblemCode.COLUMN_NOT_IN_FIRST_ROW)) + .as("A DOCTYPE must suppress COLUMN_NOT_IN_FIRST_ROW.").isEmpty(); + assertThat(onlyCode(problems, ProblemCode.FIRST_ELEMENT_WITHOUT_ATTRIBUTES)) + .as("A DOCTYPE must suppress FIRST_ELEMENT_WITHOUT_ATTRIBUTES.").isEmpty(); + } + + @Test + void testValidate_whenDtdIsNotLoaded_suppressesColumnNotInFirstRowAndFirstElementWithoutAttributes() + { + final List problems = validate( + "\n", + FlatXmlOptions.DBUNIT_DEFAULTS, true); + + assertThat(onlyCode(problems, ProblemCode.FIRST_ELEMENT_WITHOUT_ATTRIBUTES)) + .as("DTD_NOT_LOADED must suppress FIRST_ELEMENT_WITHOUT_ATTRIBUTES.").isEmpty(); + assertThat(onlyCode(problems, ProblemCode.COLUMN_NOT_IN_FIRST_ROW)) + .as("DTD_NOT_LOADED must suppress COLUMN_NOT_IN_FIRST_ROW.").isEmpty(); + assertThat(onlyCode(problems, ProblemCode.DTD_NOT_LOADED)) + .as("DTD_NOT_LOADED itself must still be reported.").hasSize(1); + } + + @Test + void testValidate_whenFirstElementHasNoAttributesButTableHasRows_reportsFirstElementWithoutAttributesAndSuppressesColumnNotInFirstRow() + { + final List problems = validate( + "", + FlatXmlOptions.DBUNIT_DEFAULTS, false); + + assertThat(onlyCode(problems, ProblemCode.FIRST_ELEMENT_WITHOUT_ATTRIBUTES)) + .as("The empty first element must be reported.").hasSize(1); + assertThat(onlyCode(problems, ProblemCode.COLUMN_NOT_IN_FIRST_ROW)).as( + "FIRST_ELEMENT_WITHOUT_ATTRIBUTES must suppress COLUMN_NOT_IN_FIRST_ROW for its table.") + .isEmpty(); + } + + @Test + void testValidate_whenATableIsSpelledTwoWays_reportsTableNameCaseVariantsAtTheSecondSpelling() + { + final String text = ""; + + final List problems = validate(text, FlatXmlOptions.DBUNIT_DEFAULTS, false); + + final DatasetProblem problem = onlyCode(problems, ProblemCode.TABLE_NAME_CASE_VARIANTS).get(0); + assertThat(text.substring(problem.offset(), problem.offset() + problem.length())) + .as("The location must be the element with the second spelling.").isEqualTo(" problems = validate( + "", new FlatXmlOptions(true, false), + false); + + assertThat(onlyCode(problems, ProblemCode.TABLE_NAME_CASE_VARIANTS)) + .as("Case-sensitive table names make USERS and users different tables, not variants.") + .isEmpty(); + } + + @Test + void testValidate_whenAColumnIsSpelledTwoWays_reportsColumnNameCaseVariantsAtTheSecondSpelling() + { + final String text = ""; + + final List problems = validate(text, FlatXmlOptions.DBUNIT_DEFAULTS, false); + + final DatasetProblem problem = onlyCode(problems, ProblemCode.COLUMN_NAME_CASE_VARIANTS).get(0); + assertThat(text.substring(problem.offset(), problem.offset() + problem.length())) + .as("The location must be the attribute with the second spelling.").isEqualTo("NAME"); + } + + @Test + void testValidate_whenTwoColumnsAreSpelledTwoWays_reportsColumnNameCaseVariantsInColumnOrder() + { + final String text = ""; + + final List problems = validate(text, FlatXmlOptions.DBUNIT_DEFAULTS, false); + + assertThat(onlyCode(problems, ProblemCode.COLUMN_NAME_CASE_VARIANTS)) + .extracting(DatasetProblem::columnName, DatasetProblem::offset) + .as("Each column's first other spelling must be reported, in column order.") + .containsExactly(tuple("A", text.indexOf("a=")), tuple("B", text.indexOf("b="))); + } + + @Test + void testValidate_whenAnElementHasTwoAttributesDifferingOnlyInCase_reportsDuplicateColumnInRowAtTheSecondAttribute() + { + final String text = ""; + + final List problems = validate(text, FlatXmlOptions.DBUNIT_DEFAULTS, false); + + final DatasetProblem problem = onlyCode(problems, ProblemCode.DUPLICATE_COLUMN_IN_ROW).get(0); + assertThat(text.substring(problem.offset(), problem.offset() + problem.length())) + .as("The location must be the second attribute.").isEqualTo("ID"); + } + + @Test + void testValidate_whenAnElementIsDeclaredOutsideTheContentModel_reportsTableNotDeclaredInDtd() + { + final List problems = validate( + "\n\n" + + "\n\n]>\n" + + "", + FlatXmlOptions.DBUNIT_DEFAULTS, false); + + assertThat(onlyCode(problems, ProblemCode.TABLE_NOT_DECLARED_IN_DTD)).as( + "AUDIT_LOG has rows but is declared outside the content model, so it is not a DTD table.") + .hasSize(1); + } + + @Test + void testValidate_whenColumnIsNotDeclaredInDtd_isWarningWithoutColumnSensingAndErrorWithIt() + { + final String text = TestDatasets.read("internal-subset.xml"); + + final List warning = validate(text, FlatXmlOptions.DBUNIT_DEFAULTS, false); + final List error = validate(text, new FlatXmlOptions(false, true), false); + + assertThat(onlyCode(warning, ProblemCode.COLUMN_NOT_DECLARED_IN_DTD).get(0).severity()) + .as("Without column sensing, dbUnit merely ignores the undeclared column's values.") + .isEqualTo(ProblemSeverity.WARNING); + assertThat(onlyCode(error, ProblemCode.COLUMN_NOT_DECLARED_IN_DTD).get(0).severity()) + .as("With column sensing, dbUnit fails to load the dataset.") + .isEqualTo(ProblemSeverity.ERROR); + } + + @Test + void testValidate_whenAnUndeclaredColumnFirstHasAValueInALaterRow_reportsColumnNotDeclaredInDtdThere() + { + final String text = "\n\n" + + "\n]>\n" + + ""; + + final List problems = validate(text, FlatXmlOptions.DBUNIT_DEFAULTS, false); + + assertThat(onlyCode(problems, ProblemCode.COLUMN_NOT_DECLARED_IN_DTD)) + .extracting(DatasetProblem::columnName, DatasetProblem::offset) + .as("The problem must point at the column's first attribute.") + .containsExactly(tuple("NAME", text.indexOf("NAME="))); + } + + @Test + void testValidate_whenTheContentModelNamesAnUndeclaredElement_reportsDtdTableWithoutDeclaration() + { + final List problems = validate( + "\n\n" + + "\n]>\n", + FlatXmlOptions.DBUNIT_DEFAULTS, false); + + assertThat(onlyCode(problems, ProblemCode.DTD_TABLE_WITHOUT_DECLARATION)) + .as("ORDERS is named in the content model but has neither ELEMENT nor ATTLIST.") + .hasSize(1); + } + + @Test + void testValidate_whenTheContentModelListsTwoSpellingsOfATable_reportsDtdTableNameCaseVariants() + { + final String text = "\n" + + "\n\n\n" + + "\n\n]>\n"; + + final List problems = validate(text, FlatXmlOptions.DBUNIT_DEFAULTS, false); + + assertThat(problems).as("Each later spelling must be reported against the first, on the DOCTYPE.") + .extracting(DatasetProblem::code, DatasetProblem::severity, DatasetProblem::message, + DatasetProblem::offset) + .containsExactly( + tuple(ProblemCode.DTD_TABLE_NAME_CASE_VARIANTS, ProblemSeverity.ERROR, + NLS.bind(Messages.Validator_dtdTableNameCaseVariants, "users", "USERS"), 0), + tuple(ProblemCode.DTD_TABLE_NAME_CASE_VARIANTS, ProblemSeverity.ERROR, + NLS.bind(Messages.Validator_dtdTableNameCaseVariants, "users", "Users"), 0)); + } + + @Test + void testValidate_whenTableNamesAreCaseSensitive_stillReportsDtdTableNameCaseVariants() + { + final String text = "\n" + + "\n\n\n" + + "\n]>\n"; + + final List problems = validate(text, new FlatXmlOptions(true, false), false); + + assertThat(problems).extracting(DatasetProblem::code) + .as("dbUnit ignores letter case in the tables of a DTD even with case-sensitive table names.") + .containsExactly(ProblemCode.DTD_TABLE_NAME_CASE_VARIANTS); + } + + @Test + void testValidate_whenTheContentModelIsEmpty_reportsDtdEmptyContentModelOnTheDoctype() + { + final String text = "\n]>\n"; + + final List problems = validate(text, FlatXmlOptions.DBUNIT_DEFAULTS, false); + + assertThat(problems).as("dbUnit cannot load an EMPTY content model, and no table name is missing.") + .extracting(DatasetProblem::code, DatasetProblem::severity, DatasetProblem::message, + DatasetProblem::offset) + .containsExactly(tuple(ProblemCode.DTD_EMPTY_CONTENT_MODEL, ProblemSeverity.ERROR, + Messages.Validator_dtdEmptyContentModel, 0)); + } + + @Test + void testValidate_whenTheContentModelHasAParameterEntityReference_reportsNoMissingDeclaration() + { + final String text = "\n\n" + + "\n]>\n"; + + final List problems = validate(text, FlatXmlOptions.DBUNIT_DEFAULTS, false); + + assertThat(problems).extracting(DatasetProblem::code) + .as("The reference is ignored with an info, and neither a phantom table nor a table that " + + "the entity may list is reported.") + .containsExactly(ProblemCode.UNSUPPORTED_DTD_CONSTRUCT); + } + + @Test + void testValidate_whenATableHasARedundantMarker_reportsRedundantEmptyElement() + { + final List problems = validate("", + FlatXmlOptions.DBUNIT_DEFAULTS, false); + + assertThat(onlyCode(problems, ProblemCode.REDUNDANT_EMPTY_ELEMENT)) + .as("A marker after a table already has rows is redundant.").hasSize(1); + } + + @Test + void testValidate_whenAMarkerComesFirstAndAnotherFollowsTheRows_reportsOnlyTheLaterMarker() + { + final String text = ""; + + final List problems = validate(text, FlatXmlOptions.DBUNIT_DEFAULTS, false); + + assertThat(onlyCode(problems, ProblemCode.REDUNDANT_EMPTY_ELEMENT)) + .extracting(DatasetProblem::offset) + .as("The marker that is the table's first element is not redundant; only the later one is.") + .containsExactly(text.lastIndexOf("")); + } + + @Test + void testValidate_whenAMarkerIsTheTablesOnlyElement_doesNotReportRedundantEmptyElement() + { + final List problems = + validate("", FlatXmlOptions.DBUNIT_DEFAULTS, false); + + assertThat(onlyCode(problems, ProblemCode.REDUNDANT_EMPTY_ELEMENT)) + .as("The table's only (first) element is not redundant.").isEmpty(); + } + + @Test + void testValidate_whenTheParserFindsIgnoredText_passesTheProblemThrough() + { + final List problems = + validate("stray", FlatXmlOptions.DBUNIT_DEFAULTS, + false); + + assertThat(onlyCode(problems, ProblemCode.TEXT_CONTENT_IGNORED)) + .as("Parser problems must be passed through in the validator's returned list.") + .hasSize(1); + } + + private static List onlyCode(final List problems, + final ProblemCode code) + { + final List result = new ArrayList<>(); + for (final DatasetProblem problem : problems) + { + if (problem.code() == code) + { + result.add(problem); + } + } + return result; + } + + private static List validate(final String text, final FlatXmlOptions options, + final boolean dtdNotLoaded) + { + final FlatXmlParseResult parse = FlatXmlParser.parse(text); + final DtdState dtdState; + final DtdDeclarations dtd; + if (parse.doctype() == null) + { + dtdState = DtdState.NONE; + dtd = null; + } + else + { + dtdState = dtdNotLoaded ? DtdState.NOT_LOADED : DtdState.LOADED; + final String internalSubset = parse.doctype().internalSubset(); + dtd = DtdReader.read(internalSubset == null ? "" : internalSubset); + } + final FlatXmlModelBuilder.Result built = + FlatXmlModelBuilder.build(text, parse, dtd, options, Map.of()); + return FlatXmlValidator.validate(parse, built.index(), built.model().getTables(), dtdState, dtd, + options); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/ParsedDataset.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/ParsedDataset.java new file mode 100644 index 00000000..74e3ed12 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/ParsedDataset.java @@ -0,0 +1,139 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import org.dbunit.eclipse.dataset.core.dtd.DtdDeclarations; +import org.dbunit.eclipse.dataset.core.dtd.DtdReader; +import org.dbunit.eclipse.dataset.core.model.DatasetModel; +import org.eclipse.jface.text.BadLocationException; +import org.eclipse.jface.text.Document; +import org.eclipse.text.edits.MultiTextEdit; +import org.eclipse.text.edits.TextEdit; + +/** + * A dataset text together with the model, the index, and the layout of one parse of it, which the tests of + * the classes that read a parse need as their input. + * + * @param text The dataset text that was parsed. + * @param model The model that the parse produced. + * @param index The index that the parse produced. + * @param layout The layout of the text, with the line delimiter that was given. + * @param options The options that the parse used. + */ +record ParsedDataset(String text, DatasetModel model, FlatXmlIndex index, FlatXmlTextLayout layout, + FlatXmlOptions options) +{ + /** + * Parses a text with dbUnit's default options and the line delimiter of a line feed. + * + * @param text The dataset text. + * @return The parse. + */ + static ParsedDataset of(final String text) + { + return of(text, FlatXmlOptions.DBUNIT_DEFAULTS, "\n"); + } + + /** + * Parses a text. + * + * @param text The dataset text. + * @param options The options of the parse. + * @param lineDelimiter The line delimiter that the layout uses for new lines. + * @return The parse. + */ + static ParsedDataset of(final String text, final FlatXmlOptions options, final String lineDelimiter) + { + return of(text, null, options, lineDelimiter); + } + + /** + * Parses a text with dbUnit's default options and the line delimiter of a line feed, the way the dataset + * document does for a DOCTYPE that brings in a DTD: the tables that the DTD declares and the text does not + * use become tables without rows. + * + * @param text The dataset text. + * @param dtdText The text of the DTD that the dataset's DOCTYPE names. + * @return The parse. + */ + static ParsedDataset withDtd(final String text, final String dtdText) + { + return of(text, DtdReader.read(dtdText), FlatXmlOptions.DBUNIT_DEFAULTS, "\n"); + } + + private static ParsedDataset of(final String text, final DtdDeclarations declarations, + final FlatXmlOptions options, final String lineDelimiter) + { + final FlatXmlParseResult parse = FlatXmlParser.parse(text); + final FlatXmlModelBuilder.Result built = + FlatXmlModelBuilder.build(text, parse, declarations, options, Map.of()); + final FlatXmlTextLayout layout = new FlatXmlTextLayout(text, lineDelimiter); + return new ParsedDataset(text, built.model(), built.index(), layout, options); + } + + /** + * Returns the context of an operation on this parse, for a document that is saved as UTF-8. + * + * @return The context. + */ + EditContext editContext() + { + return editContext(StandardCharsets.UTF_8); + } + + /** + * Returns the context of an operation on this parse. + * + * @param charset The charset that the document is saved in. + * @return The context. + */ + EditContext editContext(final Charset charset) + { + return new EditContext(index, layout, () -> charset, options, status -> + { + // These tests do not look at the log. + }); + } + + /** + * Applies planned edits to this text. + * + * @param edits The edits, which must not overlap. + * @return The text after the edits. + * @throws BadLocationException When an edit does not fit the text. + */ + String apply(final List edits) throws BadLocationException + { + final Document document = new Document(text); + final MultiTextEdit root = new MultiTextEdit(); + for (final TextEdit edit : edits) + { + root.addChild(edit); + } + root.apply(document); + return document.get(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/PendingColumnsTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/PendingColumnsTest.java new file mode 100644 index 00000000..fc9f8648 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/PendingColumnsTest.java @@ -0,0 +1,871 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.eclipse.jface.text.Document; +import org.eclipse.jface.text.IDocument; +import org.eclipse.text.undo.DocumentUndoManagerRegistry; +import org.eclipse.text.undo.IDocumentUndoManager; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link PendingColumns}: the changes of the pending columns with and without an undo manager, the + * operations for tables, the pruning after a parse, and the snapshots per modification stamp. + */ +class PendingColumnsTest +{ + @FunctionalInterface + private interface UndoManagerConsumer + { + void accept(IDocumentUndoManager undoManager) throws Exception; + } + + private static void withUndoManager(final IDocument document, final UndoManagerConsumer consumer) + throws Exception + { + DocumentUndoManagerRegistry.connect(document); + final IDocumentUndoManager undoManager = DocumentUndoManagerRegistry.getDocumentUndoManager(document); + undoManager.connect(PendingColumnsTest.class); + try + { + consumer.accept(undoManager); + } + finally + { + undoManager.disconnect(PendingColumnsTest.class); + DocumentUndoManagerRegistry.disconnect(document); + } + } + + private static DatasetTable table(final String key, final DatasetColumn... columns) + { + return new DatasetTable(key, key, List.of(columns), List.of(), false); + } + + private static DatasetColumn column(final String name, final boolean pending) + { + return new DatasetColumn(name, false, true, pending); + } + + @Test + void testAsMap_whenNothingWasAdded_isEmpty() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + + assertThat(pending.asMap()).as("A new instance has no pending columns.").isEmpty(); + } + + @Test + void testAsMap_whenAChangeFollowsTheCall_showsTheChange() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + final Map> view = pending.asMap(); + + pending.addTable("USERS", List.of("EXTRA")); + + assertThat(view).as("The view must show the pending columns as they are now.") + .containsExactly(Map.entry("USERS", List.of("EXTRA"))); + } + + @Test + void testAsMap_whenTheViewIsModified_throwsUnsupportedOperationException() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + final Map> view = pending.asMap(); + + assertThatThrownBy(() -> view.put("USERS", List.of("EXTRA"))) + .as("Only the pending columns may change their content.") + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void testAddColumn_whenNoUndoManagerIsConnected_addsTheColumnAndRefreshesOnce() + { + final AtomicInteger refreshes = new AtomicInteger(); + final PendingColumns pending = new PendingColumns(refreshes::incrementAndGet); + + pending.addColumn(new Document(), "USERS", "EXTRA"); + + assertThat(pending.asMap()).as("The column must be pending.") + .containsExactly(Map.entry("USERS", List.of("EXTRA"))); + assertThat(refreshes.get()).as("The owner must be asked to refresh once.").isEqualTo(1); + } + + @Test + void testAddColumn_whenAnotherColumnIsAdded_keepsTheOrderOfAddition() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addColumn(new Document(), "USERS", "ZETA"); + + pending.addColumn(new Document(), "USERS", "ALPHA"); + + assertThat(pending.asMap()).as("Columns must stay in the order they were added.") + .containsExactly(Map.entry("USERS", List.of("ZETA", "ALPHA"))); + } + + @Test + void testAddColumn_whenUndoneAndRedone_removesAndAddsItAndRefreshesEachTime() throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + final AtomicInteger refreshes = new AtomicInteger(); + final PendingColumns pending = new PendingColumns(refreshes::incrementAndGet); + pending.addColumn(document, "USERS", "EXTRA"); + + undoManager.undo(); + + assertThat(pending.asMap()).as("Undo must remove the column, and with it the table's entry.") + .isEmpty(); + assertThat(refreshes.get()).as("The add and the undo must each refresh.").isEqualTo(2); + undoManager.redo(); + assertThat(pending.asMap()).as("Redo must add the column again.") + .containsExactly(Map.entry("USERS", List.of("EXTRA"))); + assertThat(refreshes.get()).as("The redo must refresh too.").isEqualTo(3); + }); + } + + @Test + void testAddColumn_whenUndoneAfterTheColumnsChangedMeanwhile_setsTheColumnsFromBeforeTheAdd() + throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("USERS", List.of("A")); + pending.addColumn(document, "USERS", "B"); + pending.deleteTable("USERS"); + + undoManager.undo(); + + assertThat(pending.asMap()) + .as("Undo must set the columns that existed before the add, not take the column out " + + "of whatever is left.") + .containsExactly(Map.entry("USERS", List.of("A"))); + }); + } + + @Test + void testAddColumn_whenRedoneAfterTheColumnsChangedMeanwhile_setsTheColumnsFromAfterTheAdd() + throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addColumn(document, "USERS", "A"); + undoManager.undo(); + pending.addTable("USERS", List.of("A")); + + undoManager.redo(); + + assertThat(pending.asMap()) + .as("Redo must set the columns that existed after the add, not add the column a " + + "second time.") + .containsExactly(Map.entry("USERS", List.of("A"))); + }); + } + + @Test + void testAddColumn_whenUndoneAfterTheTextWentBack_aRestoreOfTheCurrentStampChangesNothing() + throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addColumn(document, "USERS", "EXTRA"); + pending.record(PendingColumns.modificationStampOf(document)); + document.replace(0, 0, " "); + pending.record(PendingColumns.modificationStampOf(document)); + undoManager.undo(); + undoManager.undo(); + + pending.restore(PendingColumns.modificationStampOf(document)); + + assertThat(pending.asMap()) + .as("The refresh that follows the undone add must not bring the column back from the " + + "snapshot that the add itself left at this stamp.") + .isEmpty(); + }); + } + + @Test + void testRenameColumn_whenTheOldNameDiffersInCase_renamesTheColumn() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("USERS", List.of("Extra")); + + pending.renameColumn(new Document(), "USERS", "EXTRA", "Other"); + + assertThat(pending.asMap()).as("The column must be found ignoring case.") + .containsExactly(Map.entry("USERS", List.of("Other"))); + } + + @Test + void testRenameColumn_whenUndone_restoresTheOldName() throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("USERS", List.of("EXTRA")); + pending.renameColumn(document, "USERS", "EXTRA", "OTHER"); + + undoManager.undo(); + + assertThat(pending.asMap()).as("Undo must bring the old name back.") + .containsExactly(Map.entry("USERS", List.of("EXTRA"))); + }); + } + + @Test + void testRenameColumn_whenTheTableHasNoPendingColumns_changesNothingButRefreshes() + { + final AtomicInteger refreshes = new AtomicInteger(); + final PendingColumns pending = new PendingColumns(refreshes::incrementAndGet); + + pending.renameColumn(new Document(), "USERS", "EXTRA", "OTHER"); + + assertThat(pending.asMap()).as("There is nothing to rename.").isEmpty(); + assertThat(refreshes.get()).as("The change still refreshes the owner.").isEqualTo(1); + } + + @Test + void testDeleteColumn_whenItWasTheLastOne_dropsTheTablesEntry() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("USERS", List.of("EXTRA")); + + pending.deleteColumn(new Document(), "USERS", "extra"); + + assertThat(pending.asMap()).as("An empty list must not stay behind for the table.").isEmpty(); + } + + @Test + void testDeleteColumn_whenUndone_putsTheColumnBackAtItsPosition() throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("USERS", List.of("A", "B", "C")); + pending.deleteColumn(document, "USERS", "B"); + assertThat(pending.asMap()).as("The column must be gone.") + .containsExactly(Map.entry("USERS", List.of("A", "C"))); + + undoManager.undo(); + + assertThat(pending.asMap()).as("Undo must restore the column at its old position.") + .containsExactly(Map.entry("USERS", List.of("A", "B", "C"))); + }); + } + + @Test + void testDeleteColumn_whenUndoneAfterTheTableLostItsOtherColumns_putsBothColumnsBack() throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("USERS", List.of("A", "B")); + pending.deleteColumn(document, "USERS", "B"); + pending.deleteTable("USERS"); + + undoManager.undo(); + + assertThat(pending.asMap()) + .as("Undo must set the columns that existed before the delete, though its saved " + + "position is past the end of the columns left.") + .containsExactly(Map.entry("USERS", List.of("A", "B"))); + }); + } + + @Test + void testDeleteColumn_whenTheColumnWasNotPending_undoChangesNothing() throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("USERS", List.of("A")); + pending.deleteColumn(document, "USERS", "MISSING"); + + undoManager.undo(); + + assertThat(pending.asMap()).as("A column that was not pending must not appear on undo.") + .containsExactly(Map.entry("USERS", List.of("A"))); + }); + } + + @Test + void testAddTable_whenColumnsAreGiven_recordsACopyOfThem() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + final List given = new ArrayList<>(List.of("A", "B")); + + pending.addTable("USERS", given); + given.add("C"); + + assertThat(pending.asMap()).as("Later changes of the caller's list must not reach the record.") + .containsExactly(Map.entry("USERS", List.of("A", "B"))); + } + + @Test + void testAddTable_whenNoColumnsAreGiven_recordsNothing() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + + pending.addTable("USERS", List.of()); + + assertThat(pending.asMap()).as("A table without columns has nothing pending.").isEmpty(); + } + + @Test + void testRenameTable_whenTheTableHasPendingColumns_movesThemToTheNewKey() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("USERS", List.of("A")); + + pending.renameTable("USERS", "PEOPLE"); + + assertThat(pending.asMap()).as("The columns must follow the table to its new key.") + .containsExactly(Map.entry("PEOPLE", List.of("A"))); + } + + @Test + void testRenameTable_whenTheTableHasNoPendingColumns_changesNothing() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("ORDERS", List.of("A")); + + pending.renameTable("USERS", "PEOPLE"); + + assertThat(pending.asMap()).as("Other tables must be left alone.") + .containsExactly(Map.entry("ORDERS", List.of("A"))); + } + + @Test + void testDeleteTable_whenTheTableHasPendingColumns_dropsThem() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("USERS", List.of("A")); + pending.addTable("ORDERS", List.of("B")); + + pending.deleteTable("USERS"); + + assertThat(pending.asMap()).as("Only the deleted table's columns must go.") + .containsExactly(Map.entry("ORDERS", List.of("B"))); + } + + @Test + void testKeepDataColumns_whenTheTableHasDataColumns_recordsThemInColumnOrder() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + final DatasetTable users = table("USERS", column("ID", false), column("NAME", false)); + + pending.keepDataColumns(users); + + assertThat(pending.asMap()) + .as("Every column that only the rows give the table must be kept, in column order.") + .containsExactly(Map.entry("USERS", List.of("ID", "NAME"))); + } + + @Test + void testKeepDataColumns_whenTheTableHasPendingColumns_recordsTheDataColumnsAheadOfThem() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("USERS", List.of("LATER")); + final DatasetTable users = table("USERS", column("ID", false), column("LATER", true)); + + pending.keepDataColumns(users); + + assertThat(pending.asMap()) + .as("The columns must keep the order that the table showed: data columns, then pending ones.") + .containsExactly(Map.entry("USERS", List.of("ID", "LATER"))); + } + + @Test + void testKeepDataColumns_whenAColumnIsDeclaredInTheDtd_doesNotRecordIt() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + final DatasetTable users = + table("USERS", new DatasetColumn("ID", true, true, false), column("EXTRA", false)); + + pending.keepDataColumns(users); + + assertThat(pending.asMap()).as("The DTD keeps a column that it declares, so it is not pending.") + .containsExactly(Map.entry("USERS", List.of("EXTRA"))); + } + + @Test + void testKeepDataColumns_whenOnlyDeclaredAndPendingColumnsExist_changesNothing() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("USERS", List.of("LATER")); + final DatasetTable users = + table("USERS", new DatasetColumn("ID", true, false, false), column("LATER", true)); + + pending.keepDataColumns(users); + + assertThat(pending.asMap()).as("There is no data column to keep, so the entry must stay as it is.") + .containsExactly(Map.entry("USERS", List.of("LATER"))); + } + + @Test + void testKeepDataColumns_whenNoColumnNeedsKeeping_recordsNoEntryForTheTable() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + + pending.keepDataColumns(table("USERS", new DatasetColumn("ID", true, false, false))); + + assertThat(pending.asMap()).as("A table without data columns must not get an empty entry.").isEmpty(); + } + + @Test + void testPruneBackedColumns_whenAColumnIsNowBackedByARealColumn_dropsIt() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("USERS", List.of("EXTRA", "LATER")); + final DatasetTable users = table("USERS", column("ID", false), column("extra", false), + column("LATER", true)); + + pending.pruneBackedColumns(List.of(users)); + + assertThat(pending.asMap()).as("Only a column that a real column backs must be dropped.") + .containsExactly(Map.entry("USERS", List.of("LATER"))); + } + + @Test + void testPruneBackedColumns_whenEveryPendingColumnIsBacked_dropsTheTablesEntry() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("USERS", List.of("EXTRA")); + + pending.pruneBackedColumns(List.of(table("USERS", column("EXTRA", false)))); + + assertThat(pending.asMap()).as("A table without pending columns must have no entry.").isEmpty(); + } + + @Test + void testPruneBackedColumns_whenTheTableIsNotListed_keepsItsEntry() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("USERS", List.of("EXTRA")); + pending.addTable("ORDERS", List.of("TOTAL")); + + pending.pruneBackedColumns(List.of(table("ORDERS", column("TOTAL", true)))); + + assertThat(pending.asMap()).as("A table that the build lacks may still exist, so it keeps its entry.") + .containsExactly(Map.entry("USERS", List.of("EXTRA")), Map.entry("ORDERS", List.of("TOTAL"))); + } + + @Test + void testPruneMissingTables_whenTheTableIsNotListed_dropsItsEntry() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("USERS", List.of("EXTRA")); + pending.addTable("ORDERS", List.of("TOTAL")); + + pending.pruneMissingTables(List.of(table("ORDERS", column("TOTAL", true)))); + + assertThat(pending.asMap()).as("The entry of a table that is gone must not linger.") + .containsExactly(Map.entry("ORDERS", List.of("TOTAL"))); + } + + @Test + void testPruneMissingTables_whenAListedTableHasABackedColumn_leavesItsColumnsAlone() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("USERS", List.of("EXTRA")); + + pending.pruneMissingTables(List.of(table("USERS", column("EXTRA", false)))); + + assertThat(pending.asMap()).as("Dropping missing tables must not look at the columns of listed ones.") + .containsExactly(Map.entry("USERS", List.of("EXTRA"))); + } + + @Test + void testRestore_whenAnEarlierStampIsGiven_bringsBackTheColumnsOfThatState() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.record(1L); + pending.addTable("USERS", List.of("EXTRA")); + pending.record(2L); + + pending.restore(1L); + + assertThat(pending.asMap()).as("The state recorded for stamp 1 had no pending columns.").isEmpty(); + pending.record(1L); + pending.restore(2L); + assertThat(pending.asMap()).as("The state recorded for stamp 2 must come back.") + .containsExactly(Map.entry("USERS", List.of("EXTRA"))); + } + + @Test + void testRestore_whenCalledTwiceForTheSameStamp_changesNothingTheSecondTime() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.record(1L); + pending.addTable("USERS", List.of("EXTRA")); + pending.record(2L); + pending.restore(1L); + pending.addTable("ORDERS", List.of("TOTAL")); + + pending.restore(1L); + + assertThat(pending.asMap()) + .as("The columns belong to stamp 1 once it was restored, so a second restore of it must " + + "leave them alone.") + .containsExactly(Map.entry("ORDERS", List.of("TOTAL"))); + } + + @Test + void testRestore_whenTheStampIsTheLastRecordedOne_changesNothing() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.record(5L); + pending.addTable("USERS", List.of("EXTRA")); + + pending.restore(5L); + + assertThat(pending.asMap()).as("The text has not moved to another state, so nothing comes back.") + .containsExactly(Map.entry("USERS", List.of("EXTRA"))); + } + + @Test + void testRestore_whenNothingWasRecordedForTheStamp_changesNothing() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.record(1L); + pending.addTable("USERS", List.of("EXTRA")); + + pending.restore(99L); + + assertThat(pending.asMap()).as("A stamp without a snapshot must leave the columns alone.") + .containsExactly(Map.entry("USERS", List.of("EXTRA"))); + } + + @Test + void testRecord_whenTheColumnsChangeAfterwards_keepsTheStateAsItWas() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("USERS", List.of("A")); + pending.record(1L); + pending.deleteTable("USERS"); + pending.addTable("USERS", List.of("B")); + pending.record(2L); + + pending.restore(1L); + + assertThat(pending.asMap()).as("A snapshot must hold a copy, not the live lists.") + .containsExactly(Map.entry("USERS", List.of("A"))); + } + + @Test + void testRecord_whenManyMoreSnapshotsAreRecordedThanTheMinimum_discardsNone() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + for (long stamp = 1; stamp <= 1_000; stamp++) + { + pending.deleteTable("USERS"); + pending.addTable("USERS", List.of("C" + stamp)); + pending.record(stamp); + } + pending.deleteTable("USERS"); + + pending.restore(1L); + + assertThat(pending.asMap()).as("Recording must not discard a snapshot, only keepOnly does.") + .containsExactly(Map.entry("USERS", List.of("C1"))); + } + + @Test + void testKeepOnly_whenMoreSnapshotsExistThanAreKept_discardsTheOldestFirst() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + for (long stamp = 1; stamp <= 5; stamp++) + { + pending.deleteTable("USERS"); + pending.addTable("USERS", List.of("C" + stamp)); + pending.record(stamp); + } + pending.deleteTable("USERS"); + + pending.keepOnly(3); + + pending.restore(2L); + assertThat(pending.asMap()).as("The two oldest of five snapshots must be gone.").isEmpty(); + pending.restore(3L); + assertThat(pending.asMap()).as("The third, the oldest of the three kept, must still be there.") + .containsExactly(Map.entry("USERS", List.of("C3"))); + } + + @Test + void testKeepOnly_whenAsManySnapshotsExistAsAreKept_discardsNone() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + for (long stamp = 1; stamp <= 3; stamp++) + { + pending.deleteTable("USERS"); + pending.addTable("USERS", List.of("C" + stamp)); + pending.record(stamp); + } + pending.deleteTable("USERS"); + + pending.keepOnly(3); + + pending.restore(1L); + assertThat(pending.asMap()).as("Three snapshots fit the three that are kept.") + .containsExactly(Map.entry("USERS", List.of("C1"))); + } + + @Test + void testKeepOnly_whenNoSnapshotIsKept_discardsAll() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("USERS", List.of("C1")); + pending.record(1L); + pending.addTable("ORDERS", List.of("C2")); + pending.record(2L); + pending.deleteTable("USERS"); + pending.deleteTable("ORDERS"); + + pending.keepOnly(0); + + pending.restore(1L); + assertThat(pending.asMap()).as("With none kept, no snapshot can come back.").isEmpty(); + } + + @Test + void testSnapshotsToKeep_whenTheDocumentHasNoUndoManager_isTwoHundred() + { + final IDocument document = new Document(""); + + assertThat(PendingColumns.snapshotsToKeep(document)) + .as("Without an undo history, the minimum of 200 snapshots is kept.").isEqualTo(200); + } + + @Test + void testSnapshotsToKeep_whenTheUndoHistoryKeepsManySteps_isFourForEachStep() throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + undoManager.setMaximalUndoLevel(1_000); + + assertThat(PendingColumns.snapshotsToKeep(document)) + .as("A step of the undo history may need a snapshot, and a few more come between steps.") + .isEqualTo(4_000); + }); + } + + @Test + void testSnapshotsToKeep_whenTheUndoHistoryKeepsFewSteps_isTwoHundred() throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + undoManager.setMaximalUndoLevel(10); + + assertThat(PendingColumns.snapshotsToKeep(document)) + .as("A short undo history must not lower the minimum of 200 snapshots.").isEqualTo(200); + }); + } + + @Test + void testSnapshotsToKeep_whenTheUndoHistoryIsAsLongAsCanBe_doesNotOverflow() throws Exception + { + final IDocument document = new Document(""); + withUndoManager(document, undoManager -> + { + undoManager.setMaximalUndoLevel(Integer.MAX_VALUE); + + assertThat(PendingColumns.snapshotsToKeep(document)) + .as("Four snapshots for each of that many steps must stay a positive number.") + .isEqualTo(Integer.MAX_VALUE); + }); + } + + @Test + void testRekey_whenATableHasANewKey_movesItsColumnsToIt() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("USERS", List.of("A", "B")); + pending.addTable("ORDERS", List.of("C")); + + pending.rekey(Map.of("USERS", "users")); + + assertThat(pending.asMap()) + .as("The columns of a table that has a new key must follow it, and those of another stay.") + .containsExactly(Map.entry("users", List.of("A", "B")), Map.entry("ORDERS", List.of("C"))); + } + + @Test + void testRekey_whenTwoKeysBecomeOne_mergesTheirColumnsInOrderWithoutRepeats() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("users", List.of("A", "B")); + pending.addTable("USERS", List.of("B", "C")); + + pending.rekey(Map.of("users", "USERS", "USERS", "USERS")); + + assertThat(pending.asMap()).as("Both spellings become one table, whose columns are listed once.") + .containsExactly(Map.entry("USERS", List.of("A", "B", "C"))); + } + + @Test + void testRekey_whenAKeyIsNotInTheMapping_keepsItsEntry() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("GONE", List.of("A")); + + pending.rekey(Map.of("USERS", "users")); + + assertThat(pending.asMap()).as("A table that the model lacks may come back, so it keeps its entry.") + .containsExactly(Map.entry("GONE", List.of("A"))); + } + + @Test + void testRekey_whenSnapshotsWereRecorded_forgetsThemBecauseTheirKeysAreOld() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("USERS", List.of("A")); + pending.record(1L); + pending.record(2L); + + pending.rekey(Map.of("USERS", "users")); + pending.deleteTable("users"); + pending.restore(1L); + + assertThat(pending.asMap()).as("A snapshot with the old key must not come back.").isEmpty(); + } + + @Test + void testForgetHistory_whenSnapshotsWereRecorded_forgetsThem() + { + final PendingColumns pending = new PendingColumns(() -> + { + }); + pending.addTable("USERS", List.of("A")); + pending.record(1L); + pending.record(2L); + pending.deleteTable("USERS"); + + pending.forgetHistory(); + pending.restore(1L); + + assertThat(pending.asMap()).as("No snapshot may survive being bound to another document.").isEmpty(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/RowEditsTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/RowEditsTest.java new file mode 100644 index 00000000..34d51eb6 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/RowEditsTest.java @@ -0,0 +1,454 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; + +import org.dbunit.eclipse.dataset.core.Messages; +import org.dbunit.eclipse.dataset.core.edit.DatasetEditException; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.eclipse.osgi.util.NLS; +import org.eclipse.text.edits.TextEdit; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link RowEdits}: the edits that it plans to duplicate, delete, and move rows, applied to the text, + * and the selections and moves that it refuses. + */ +class RowEditsTest +{ + private static final String BOB = " \n"; + + private static final String ALICE = " \n"; + + private static final String CARL = " \n"; + + private static final String ORDER = " \n"; + + private static final String BOB_WITH_EMAIL = " \n"; + + private static final String CARL_WITH_EMAIL = " \n"; + + private static final String TEXT = dataset(BOB, ALICE, CARL, ORDER); + + private static String dataset(final String... lines) + { + return "\n" + String.join("", lines) + "\n"; + } + + private static RowEdits rowEditsFor(final ParsedDataset parsed) + { + return new RowEdits(parsed.editContext()); + } + + private static DatasetTable usersOf(final ParsedDataset parsed) + { + return parsed.model().findTable("USERS").orElseThrow(); + } + + private static String duplicated(final String text, final int... rowIndexes) throws Exception + { + final ParsedDataset parsed = ParsedDataset.of(text); + return parsed.apply(rowEditsFor(parsed).duplicateEdits("USERS", usersOf(parsed), rowIndexes)); + } + + private static String deleted(final String text, final int... rowIndexes) throws Exception + { + final ParsedDataset parsed = ParsedDataset.of(text); + return parsed.apply(rowEditsFor(parsed).deleteEdits("USERS", usersOf(parsed), rowIndexes)); + } + + private static String deleted(final ParsedDataset parsed, final int... rowIndexes) throws Exception + { + return parsed.apply(rowEditsFor(parsed).deleteEdits("USERS", usersOf(parsed), rowIndexes)); + } + + private static List moveEditsOf(final ParsedDataset parsed, final int firstRowIndex, + final int rowCount, final int delta) + { + return rowEditsFor(parsed).moveEdits("USERS", usersOf(parsed), firstRowIndex, rowCount, delta); + } + + private static String moved(final int firstRowIndex, final int rowCount, final int delta) throws Exception + { + final ParsedDataset parsed = ParsedDataset.of(TEXT); + final List edits = + rowEditsFor(parsed).moveEdits("USERS", usersOf(parsed), firstRowIndex, rowCount, delta); + return parsed.apply(edits); + } + + @Test + void testDuplicateEdits_whenOneRowIsGiven_insertsACopyRightAfterIt() throws Exception + { + assertThat(duplicated(TEXT, 0)).as("The copy must follow its row on a line of its own.") + .isEqualTo(dataset(BOB, BOB, ALICE, CARL, ORDER)); + } + + @Test + void testDuplicateEdits_whenSeveralRowsAreGiven_insertsTheCopiesAfterTheLastOfThem() throws Exception + { + assertThat(duplicated(TEXT, 0, 2)).as("The copies go after the last selected row, in row order.") + .isEqualTo(dataset(BOB, ALICE, CARL, BOB, CARL, ORDER)); + } + + @Test + void testDuplicateEdits_whenTheRowsAreNotInOrder_copiesThemInRowOrder() throws Exception + { + assertThat(duplicated(TEXT, 2, 0)).as("The selection is sorted first.") + .isEqualTo(dataset(BOB, ALICE, CARL, BOB, CARL, ORDER)); + } + + @Test + void testDuplicateEdits_whenARowIsGivenTwice_copiesItTwice() throws Exception + { + assertThat(duplicated(TEXT, 1, 1)).as("A repeated row index is not removed.") + .isEqualTo(dataset(BOB, ALICE, ALICE, ALICE, CARL, ORDER)); + } + + @Test + void testDuplicateEdits_whenNoRowIsGiven_makesNoEdit() + { + final ParsedDataset parsed = ParsedDataset.of(TEXT); + + final List edits = rowEditsFor(parsed).duplicateEdits("USERS", usersOf(parsed), new int[0]); + + assertThat(edits).as("Nothing selected means nothing to copy.").isEmpty(); + } + + @Test + void testDuplicateEdits_whenARowDoesNotExist_throwsDatasetEditException() + { + assertThatThrownBy(() -> duplicated(TEXT, 3)).as("Only rows of the table can be copied.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_rowOutOfRange, 3, "USERS")); + } + + @Test + void testDeleteEdits_whenOneRowIsGiven_removesItsWholeLine() throws Exception + { + assertThat(deleted(TEXT, 1)).as("No blank line may be left behind.") + .isEqualTo(dataset(BOB, CARL, ORDER)); + } + + @Test + void testDeleteEdits_whenSeveralRowsAreGiven_removesEachLine() throws Exception + { + assertThat(deleted(TEXT, 2, 0)).as("Every selected row must go, whatever the order of the selection.") + .isEqualTo(dataset(ALICE, ORDER)); + } + + @Test + void testDeleteEdits_whenEveryRowOfATableWithoutMarkerIsGiven_leavesAnEmptyElement() throws Exception + { + assertThat(deleted(TEXT, 0, 1, 2)).as("The table must stay in the document as an empty element.") + .isEqualTo(dataset(" \n", ORDER)); + } + + @Test + void testDeleteEdits_whenEveryRowOfATableWithAMarkerIsGiven_removesAllTheRows() throws Exception + { + final String text = dataset(" \n", " \n"); + + assertThat(deleted(text, 0)).as("The marker already keeps the table in the document.") + .isEqualTo(dataset(" \n")); + } + + @Test + void testDeleteEdits_whenEveryRowOfATableWithDefaultValuesIsGiven_leavesNoEmptyElement() throws Exception + { + final String text = dataset(" \n", " \n", ORDER); + final ParsedDataset parsed = ParsedDataset.withDtd(text, + "\n\n\n" + + "\n" + + ""); + + final List edits = + rowEditsFor(parsed).deleteEdits("USERS", usersOf(parsed), new int[] { 0, 1 }); + + assertThat(parsed.apply(edits)).as("An empty element would be a row of default values in dbUnit, " + + "so every row must go; the DTD keeps the table.").isEqualTo(dataset(ORDER)); + } + + @Test + void testDeleteEdits_whenAnEmptyElementIsARowOfDefaultValues_removesItsLineLikeAnyRow() throws Exception + { + final String text = dataset(" \n", " \n"); + final ParsedDataset parsed = ParsedDataset.withDtd(text, + "\n\n" + + ""); + + final List edits = + rowEditsFor(parsed).deleteEdits("USERS", usersOf(parsed), new int[] { 1 }); + + assertThat(parsed.apply(edits)).as("The empty element is the table's second row.") + .isEqualTo(dataset(" \n")); + } + + @Test + void testDeleteEdits_whenTheRowSharesItsLine_removesTheElementAlone() throws Exception + { + final String text = "\n"; + + assertThat(deleted(text, 0)).as("A row on a shared line must not take the line with it.") + .isEqualTo("\n"); + } + + @Test + void testDeleteEdits_whenNoRowIsGiven_makesNoEdit() + { + final ParsedDataset parsed = ParsedDataset.of(TEXT); + + final List edits = rowEditsFor(parsed).deleteEdits("USERS", usersOf(parsed), new int[0]); + + assertThat(edits).as("Nothing selected means nothing to delete.").isEmpty(); + } + + @Test + void testDeleteEdits_whenARowDoesNotExist_throwsDatasetEditException() + { + assertThatThrownBy(() -> deleted(TEXT, -1)).as("Only rows of the table can be deleted.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_rowOutOfRange, -1, "USERS")); + } + + @Test + void testDeleteEdits_whenTheNextRowLacksAColumnThatAnotherRowHas_throwsDatasetEditException() + { + final ParsedDataset parsed = ParsedDataset.of(dataset(BOB_WITH_EMAIL, ALICE, CARL_WITH_EMAIL)); + + assertThatThrownBy(() -> deleted(parsed, 0)) + .as("Alice would become the first row without an EMAIL, which dbUnit would ignore in Carl.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_firstRowWouldLoseColumn, + new Object[] { "EMAIL", "USERS" })); + } + + @Test + void testDeleteEdits_whenNoRemainingRowHasTheColumnTheNextRowLacks_isAllowed() throws Exception + { + final ParsedDataset parsed = ParsedDataset.of(dataset(BOB_WITH_EMAIL, ALICE, CARL)); + + assertThat(deleted(parsed, 0)).as("Nothing is lost when no remaining row has an EMAIL.") + .isEqualTo(dataset(ALICE, CARL)); + } + + @Test + void testDeleteEdits_whenTheNextRowHasEveryColumnOfTheDeletedFirstRow_isAllowed() throws Exception + { + final ParsedDataset parsed = ParsedDataset.of(dataset(BOB, CARL_WITH_EMAIL)); + + assertThat(deleted(parsed, 0)).as("The new first row has more columns than the old one.") + .isEqualTo(dataset(CARL_WITH_EMAIL)); + } + + @Test + void testDeleteEdits_whenTheFirstTwoRowsGoAndTheThirdHasEveryColumn_isAllowed() throws Exception + { + final ParsedDataset parsed = ParsedDataset.of(dataset(BOB_WITH_EMAIL, ALICE, CARL_WITH_EMAIL)); + + assertThat(deleted(parsed, 0, 1)).as("The third row becomes the first row, and it has every column.") + .isEqualTo(dataset(CARL_WITH_EMAIL)); + } + + @Test + void testDeleteEdits_whenARowAfterTheFirstGoes_isAllowedWhateverItHas() throws Exception + { + final ParsedDataset parsed = ParsedDataset.of(dataset(BOB_WITH_EMAIL, ALICE, CARL_WITH_EMAIL)); + + assertThat(deleted(parsed, 1)).as("The first row stays, so dbUnit still takes its columns.") + .isEqualTo(dataset(BOB_WITH_EMAIL, CARL_WITH_EMAIL)); + } + + @Test + void testDeleteEdits_whenEveryRowGoesAndTheTableHasNoOtherRow_isAllowed() throws Exception + { + final ParsedDataset parsed = ParsedDataset.of(dataset(BOB_WITH_EMAIL, ALICE, CARL_WITH_EMAIL)); + + assertThat(deleted(parsed, 0, 1, 2)).as("No row stays, so no value is lost.") + .isEqualTo(dataset(" \n")); + } + + @Test + void testDeleteEdits_whenTheDocumentHasADtd_allowsTheNextRowToLackAColumn() throws Exception + { + final String text = + "" + dataset(BOB_WITH_EMAIL, ALICE, CARL_WITH_EMAIL); + final ParsedDataset parsed = ParsedDataset.withDtd(text, + ""); + + assertThat(deleted(parsed, 0)).as("With a DTD, dbUnit takes the columns from the DTD.") + .isEqualTo("" + dataset(ALICE, CARL_WITH_EMAIL)); + } + + @Test + void testDeleteEdits_whenColumnSensingIsOn_allowsTheNextRowToLackAColumn() throws Exception + { + final ParsedDataset parsed = ParsedDataset.of(dataset(BOB_WITH_EMAIL, ALICE, CARL_WITH_EMAIL), + new FlatXmlOptions(false, true), "\n"); + + assertThat(deleted(parsed, 0)).as("With column sensing, dbUnit adds the columns of every row.") + .isEqualTo(dataset(ALICE, CARL_WITH_EMAIL)); + } + + @Test + void testMoveEdits_whenARowThatLacksAColumnMovesToTheTop_throwsDatasetEditException() + { + final ParsedDataset parsed = ParsedDataset.of(dataset(BOB_WITH_EMAIL, ALICE)); + + assertThatThrownBy(() -> moveEditsOf(parsed, 1, 1, -1)) + .as("Alice would become the first row without an EMAIL, which Bob still has.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_firstRowWouldLoseColumn, + new Object[] { "EMAIL", "USERS" })); + } + + @Test + void testMoveEdits_whenTheFirstRowMovesBelowARowThatLacksAColumn_throwsDatasetEditException() + { + final ParsedDataset parsed = ParsedDataset.of(dataset(BOB_WITH_EMAIL, ALICE)); + + assertThatThrownBy(() -> moveEditsOf(parsed, 0, 1, 1)) + .as("Alice would become the first row without an EMAIL, which Bob still has.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_firstRowWouldLoseColumn, + new Object[] { "EMAIL", "USERS" })); + } + + @Test + void testMoveEdits_whenABlockMovesUpToTheTopAndItsFirstRowLacksAColumn_throwsDatasetEditException() + { + final ParsedDataset parsed = ParsedDataset.of(dataset(BOB_WITH_EMAIL, ALICE, CARL_WITH_EMAIL)); + + assertThatThrownBy(() -> moveEditsOf(parsed, 1, 2, -1)) + .as("The block's first row, Alice, would become the first row.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_firstRowWouldLoseColumn, + new Object[] { "EMAIL", "USERS" })); + } + + @Test + void testMoveEdits_whenABlockMovesDownFromTheTopAndTheRowBelowItLacksAColumn_throwsDatasetEditException() + { + final ParsedDataset parsed = ParsedDataset.of(dataset(BOB_WITH_EMAIL, CARL_WITH_EMAIL, ALICE)); + + assertThatThrownBy(() -> moveEditsOf(parsed, 0, 2, 1)) + .as("The row below the block, Alice, would become the first row.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_firstRowWouldLoseColumn, + new Object[] { "EMAIL", "USERS" })); + } + + @Test + void testMoveEdits_whenTheRowMovedToTheTopHasEveryColumn_isAllowed() throws Exception + { + final ParsedDataset parsed = ParsedDataset.of(dataset(ALICE, BOB_WITH_EMAIL)); + + assertThat(parsed.apply(moveEditsOf(parsed, 1, 1, -1))) + .as("Bob has every column of Alice, so dbUnit keeps loading them all.") + .isEqualTo(dataset(BOB_WITH_EMAIL, ALICE)); + } + + @Test + void testMoveEdits_whenTheMoveDoesNotReachTheTop_isAllowedWhateverTheRowsHave() throws Exception + { + final ParsedDataset parsed = ParsedDataset.of(dataset(BOB_WITH_EMAIL, ALICE, CARL_WITH_EMAIL)); + + assertThat(parsed.apply(moveEditsOf(parsed, 2, 1, -1))) + .as("The first row stays, so dbUnit still takes its columns.") + .isEqualTo(dataset(BOB_WITH_EMAIL, CARL_WITH_EMAIL, ALICE)); + } + + @Test + void testMoveEdits_whenTheDocumentHasADtd_allowsARowThatLacksAColumnToMoveToTheTop() throws Exception + { + final String text = "" + dataset(BOB_WITH_EMAIL, ALICE); + final ParsedDataset parsed = ParsedDataset.withDtd(text, + ""); + + assertThat(parsed.apply(moveEditsOf(parsed, 1, 1, -1))) + .as("With a DTD, dbUnit takes the columns from the DTD.") + .isEqualTo("" + dataset(ALICE, BOB_WITH_EMAIL)); + } + + @Test + void testMoveEdits_whenABlockMovesDown_swapsItWithTheRowBelow() throws Exception + { + assertThat(moved(0, 2, 1)).as("The block of two rows must pass the third row.") + .isEqualTo(dataset(CARL, BOB, ALICE, ORDER)); + } + + @Test + void testMoveEdits_whenARowMovesUp_swapsItWithTheRowAbove() throws Exception + { + assertThat(moved(1, 1, -1)).as("The second row must pass the first.") + .isEqualTo(dataset(ALICE, BOB, CARL, ORDER)); + } + + @Test + void testMoveEdits_whenABlockMovesUp_swapsItWithTheRowAbove() throws Exception + { + assertThat(moved(1, 2, -1)).as("The block of two rows must pass the first row.") + .isEqualTo(dataset(ALICE, CARL, BOB, ORDER)); + } + + @Test + void testMoveEdits_whenTheBlockIsNotInTheTable_throwsDatasetEditException() + { + final String message = NLS.bind(Messages.Edit_rowBlockOutOfRange, "USERS"); + + assertThatThrownBy(() -> moved(-1, 1, 1)).as("The block cannot start before the first row.") + .isInstanceOf(DatasetEditException.class).hasMessage(message); + assertThatThrownBy(() -> moved(0, 0, 1)).as("The block cannot be empty.") + .isInstanceOf(DatasetEditException.class).hasMessage(message); + assertThatThrownBy(() -> moved(2, 2, -1)).as("The block cannot reach past the last row.") + .isInstanceOf(DatasetEditException.class).hasMessage(message); + } + + @Test + void testMoveEdits_whenTheDistanceIsNotOnePosition_throwsDatasetEditException() + { + assertThatThrownBy(() -> moved(1, 1, 2)).as("Rows move by one position at a time.") + .isInstanceOf(DatasetEditException.class).hasMessage(Messages.Edit_moveByOnePosition); + assertThatThrownBy(() -> moved(1, 1, 0)).as("Standing still is not a move.") + .isInstanceOf(DatasetEditException.class).hasMessage(Messages.Edit_moveByOnePosition); + } + + @Test + void testMoveEdits_whenTheFirstRowMovesUp_throwsDatasetEditException() + { + assertThatThrownBy(() -> moved(0, 1, -1)).as("There is no row above the first one.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_moveBeforeFirstRow, "USERS")); + } + + @Test + void testMoveEdits_whenTheLastRowMovesDown_throwsDatasetEditException() + { + assertThatThrownBy(() -> moved(2, 1, 1)).as("There is no row below the last one.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_moveAfterLastRow, "USERS")); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/RowInsertEditsTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/RowInsertEditsTest.java new file mode 100644 index 00000000..6769011a --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/RowInsertEditsTest.java @@ -0,0 +1,378 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; + +import org.dbunit.eclipse.dataset.core.Messages; +import org.dbunit.eclipse.dataset.core.edit.DatasetEditException; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.eclipse.osgi.util.NLS; +import org.eclipse.text.edits.TextEdit; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link RowInsertEdits}: where new rows go in each situation, how they are written, and the + * positions and rows that it refuses. + */ +class RowInsertEditsTest +{ + private static final String TEXT = "\n" + " \n" + + " \n" + " \n" + "\n"; + + private static List row(final String... values) + { + return Arrays.asList(values); + } + + private static String beforeBob(final String lines) + { + return TEXT.replace(" \n", "NAME=\"Alice\"/>\n" + lines); + } + + private static List plan(final ParsedDataset parsed, final String tableKey, final int rowIndex, + final List> rows) + { + final DatasetTable table = parsed.model().findTable(tableKey).orElseThrow(); + return new RowInsertEdits(parsed.editContext()).plan(tableKey, table, rowIndex, rows); + } + + private static String result(final ParsedDataset parsed, final String tableKey, final int rowIndex, + final List> rows) throws Exception + { + return parsed.apply(plan(parsed, tableKey, rowIndex, rows)); + } + + @Test + void testPlan_whenInsertingBeforeTheFirstRow_putsTheRowOnItsOwnLineWithTheSameIndentation() + throws Exception + { + final String result = result(ParsedDataset.of(TEXT), "USERS", 0, List.of(row("3", "Carl"))); + + assertThat(result).as("The new row must come before the first row, indented like it.") + .isEqualTo(beforeBob(" \n")); + } + + @Test + void testPlan_whenInsertingBetweenRows_putsTheRowBeforeTheRowAtThatIndex() throws Exception + { + final String result = result(ParsedDataset.of(TEXT), "USERS", 1, List.of(row("3", "Carl"))); + + assertThat(result).as("The new row must take the place of the second row, which moves down.") + .isEqualTo(beforeAlice(" \n")); + } + + @Test + void testPlan_whenInsertingAfterTheLastRowOfTheTable_putsTheRowRightAfterIt() throws Exception + { + final String result = result(ParsedDataset.of(TEXT), "USERS", 2, List.of(row("3", "Carl"))); + + assertThat(result).as("The new row must follow the table's last row, not the end of the document.") + .isEqualTo(afterAlice(" \n")); + } + + @Test + void testPlan_whenSeveralRowsAreInserted_writesThemInOrderBeforeTheAnchor() throws Exception + { + final String result = + result(ParsedDataset.of(TEXT), "USERS", 1, List.of(row("3", "Carl"), row("4", "Dora"))); + + assertThat(result).as("The rows must keep the order that they were given in.") + .isEqualTo(beforeAlice( + " \n \n")); + } + + @Test + void testPlan_whenAValueIsNull_leavesItsAttributeOut() throws Exception + { + final String result = result(ParsedDataset.of(TEXT), "USERS", 2, List.of(row("3", null))); + + assertThat(result).as("A NULL cell has no attribute.") + .isEqualTo(TEXT.replace("NAME=\"Alice\"/>\n", "NAME=\"Alice\"/>\n \n")); + } + + @Test + void testPlan_whenAValueNeedsEscaping_escapesIt() throws Exception + { + final String result = result(ParsedDataset.of(TEXT), "USERS", 2, List.of(row("3", "a&b\n", + "NAME=\"Alice\"/>\n \n")); + } + + @Test + void testPlan_whenTheCharsetCannotEncodeACharacter_writesACharacterReference() throws Exception + { + final ParsedDataset parsed = ParsedDataset.of(TEXT); + final DatasetTable table = parsed.model().findTable("USERS").orElseThrow(); + final EditContext asciiContext = parsed.editContext(StandardCharsets.US_ASCII); + + final RowInsertEdits rowInsertEdits = new RowInsertEdits(asciiContext); + + final List edits = rowInsertEdits.plan("USERS", table, 2, List.of(row("3", "é"))); + + assertThat(parsed.apply(edits)).as("The encoder of the context decides what needs a reference.") + .isEqualTo(afterAlice(" \n")); + } + + @Test + void testPlan_whenTheLineDelimiterIsCrLf_usesItBetweenTheLines() throws Exception + { + final String text = TEXT.replace("\n", "\r\n"); + final ParsedDataset parsed = ParsedDataset.of(text, FlatXmlOptions.DBUNIT_DEFAULTS, "\r\n"); + + final String result = result(parsed, "USERS", 2, List.of(row("3", "Carl"))); + + assertThat(result).as("The new line must end like the others.") + .isEqualTo(text.replace("NAME=\"Alice\"/>\r\n", + "NAME=\"Alice\"/>\r\n \r\n")); + } + + @Test + void testPlan_whenTheTableIsOnlyAMarker_replacesTheMarkerWithTheRows() throws Exception + { + final String text = "\n \n \n\n"; + final ParsedDataset parsed = ParsedDataset.withDtd(text, + "" + + ""); + + final String result = result(parsed, "ORDERS", 0, List.of(row("7", "9"), row("8", "5"))); + + assertThat(result).as("The marker element must give way to the rows.") + .isEqualTo("\n \n \n" + + " \n\n"); + } + + @Test + void testPlan_whenTheTableIsOnlyDeclared_putsTheRowsAtTheEndOfTheDataset() throws Exception + { + final String text = "\n \n\n"; + final ParsedDataset parsed = ParsedDataset.withDtd(text, + "" + + ""); + + final String result = result(parsed, "ORDERS", 0, List.of(row("7"))); + + assertThat(result).as("A table without elements gets its rows as the last children of the root.") + .isEqualTo("\n \n \n\n"); + } + + @Test + void testPlan_whenNoRowIsGiven_makesNoEdit() + { + final List edits = plan(ParsedDataset.of(TEXT), "USERS", 1, List.of()); + + assertThat(edits).as("Nothing to insert means nothing to edit.").isEmpty(); + } + + @Test + void testPlan_whenTheRowIndexIsOutOfRange_throwsDatasetEditException() + { + final ParsedDataset parsed = ParsedDataset.of(TEXT); + + assertThatThrownBy(() -> plan(parsed, "USERS", -1, List.of(row("3", "Carl")))) + .as("A negative position cannot be used.").isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_rowOutOfRange, -1, "USERS")); + assertThatThrownBy(() -> plan(parsed, "USERS", 3, List.of(row("3", "Carl")))) + .as("A position past the end of the table cannot be used.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_rowOutOfRange, 3, "USERS")); + } + + @Test + void testPlan_whenTheTableHasNoColumns_throwsDatasetEditException() + { + final ParsedDataset parsed = ParsedDataset.withDtd("", + ""); + + assertThatThrownBy(() -> plan(parsed, "BARE", 0, List.of(row()))) + .as("A row cannot be written without columns.").isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_tableHasNoColumns, "BARE")); + } + + @Test + void testPlan_whenARowHasTheWrongNumberOfValues_throwsDatasetEditException() + { + final ParsedDataset parsed = ParsedDataset.of(TEXT); + + assertThatThrownBy(() -> plan(parsed, "USERS", 0, List.of(row("3")))) + .as("A row needs one value for each column.").isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_wrongValueCount, 2, "USERS")); + } + + @Test + void testPlan_whenARowHasOnlyNullValues_throwsDatasetEditException() + { + final ParsedDataset parsed = ParsedDataset.of(TEXT); + + assertThatThrownBy(() -> plan(parsed, "USERS", 0, List.of(row(null, null)))) + .as("A row without a value would be an element without attributes.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_newRowWouldBeEmpty, "USERS")); + } + + @Test + void testPlan_whenTheNewFirstRowLacksAColumnOfTheOldFirstRow_throwsDatasetEditException() + { + final ParsedDataset parsed = ParsedDataset.of(TEXT); + + assertThatThrownBy(() -> plan(parsed, "USERS", 0, List.of(row("3", null)))) + .as("dbUnit would take only ID from the new first row and ignore every NAME.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_firstRowWouldLoseColumn, + new Object[] { "NAME", "USERS" })); + } + + @Test + void testPlan_whenSeveralRowsAreInsertedAtTheTopAndTheFirstOfThemLacksAColumn_throwsDatasetEditException() + { + final ParsedDataset parsed = ParsedDataset.of(TEXT); + + assertThatThrownBy(() -> plan(parsed, "USERS", 0, List.of(row("3", null), row("4", "Dan")))) + .as("The first of the new rows becomes the first row, whatever the others have.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_firstRowWouldLoseColumn, + new Object[] { "NAME", "USERS" })); + } + + @Test + void testPlan_whenOnlyALaterNewRowLacksAColumn_isAllowed() throws Exception + { + final String result = + result(ParsedDataset.of(TEXT), "USERS", 0, List.of(row("3", "Carl"), row("4", null))); + + assertThat(result).as("A row after the first may lack a column.") + .isEqualTo(beforeBob(" \n \n")); + } + + @Test + void testPlan_whenTheNewFirstRowHasAnEmptyStringForTheColumn_isAllowed() throws Exception + { + final String result = result(ParsedDataset.of(TEXT), "USERS", 0, List.of(row("3", ""))); + + assertThat(result).as("An empty string is a value, so dbUnit keeps the column.") + .isEqualTo(beforeBob(" \n")); + } + + @Test + void testPlan_whenTheNewFirstRowLacksAColumnAndTheDocumentHasADtd_isAllowed() throws Exception + { + final String text = "" + TEXT; + final ParsedDataset parsed = ParsedDataset.withDtd(text, + "" + + "" + + ""); + + assertThat(result(parsed, "USERS", 0, List.of(row("3", null)))) + .as("With a DTD, dbUnit takes the columns from the DTD, not from the first row.") + .isEqualTo(text.replace(" \n \n")); + } + + @Test + void testPlan_whenAnEmptyElementComesBeforeTheFirstRow_allowsANewFirstRowThatLacksAColumn() + throws Exception + { + final String text = "\n \n \n\n"; + + assertThat(result(ParsedDataset.of(text), "USERS", 0, List.of(row("3", null)))) + .as("The empty element stays the first element, so dbUnit has no columns to keep.") + .isEqualTo(text.replace(" \n blankRow(final ParsedDataset parsed, final int rowIndex) + { + final DatasetTable table = parsed.model().findTable("USERS").orElseThrow(); + return new RowInsertEdits(parsed.editContext()).blankRow("USERS", table, rowIndex); + } + + @Test + void testBlankRow_whenItBecomesTheFirstRow_hasAnEmptyStringForEveryColumnOfTheOldFirstRow() + { + assertThat(blankRow(ParsedDataset.of(TEXT), 0)) + .as("dbUnit takes ID and NAME from the first row, so the blank row needs both.") + .containsExactly("", ""); + } + + @Test + void testBlankRow_whenItIsNotTheFirstRow_hasAnEmptyStringInTheFirstColumnOnly() + { + assertThat(blankRow(ParsedDataset.of(TEXT), 1)) + .as("A row after the first needs only a value that makes it a row.") + .containsExactly("", null); + } + + @Test + void testBlankRow_whenTheOldFirstRowHasFewerColumnsThanTheTable_hasOnlyTheColumnsOfTheOldFirstRow() + { + final ParsedDataset parsed = ParsedDataset + .of("\n \n \n\n"); + + assertThat(blankRow(parsed, 0)).as("dbUnit takes only ID from the first row, so only ID is needed.") + .containsExactly("", null); + } + + @Test + void testBlankRow_whenTheDocumentHasADtd_hasAnEmptyStringInTheFirstColumnOnly() + { + final ParsedDataset parsed = ParsedDataset.withDtd("" + TEXT, + "" + + "" + + ""); + + assertThat(blankRow(parsed, 0)).as("With a DTD, the first row does not decide the columns.") + .containsExactly("", null); + } + + @Test + void testBlankRow_whenColumnSensingIsOn_hasAnEmptyStringInTheFirstColumnOnly() + { + final ParsedDataset parsed = ParsedDataset.of(TEXT, new FlatXmlOptions(false, true), "\n"); + + assertThat(blankRow(parsed, 0)).as("With column sensing, the first row does not decide the columns.") + .containsExactly("", null); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/ScanProblemsTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/ScanProblemsTest.java new file mode 100644 index 00000000..efe378f8 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/ScanProblemsTest.java @@ -0,0 +1,198 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.dbunit.eclipse.dataset.core.model.DatasetProblem; +import org.dbunit.eclipse.dataset.core.model.ProblemCode; +import org.dbunit.eclipse.dataset.core.model.ProblemSeverity; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link ScanProblems}: what it records for informational and blocking problems, and the line and + * column that it adds to a blocking problem's message. + */ +class ScanProblemsTest +{ + private static DatasetProblem errorAt(final String message, final int offset) + { + return new DatasetProblem(ProblemCode.NOT_WELL_FORMED, ProblemSeverity.ERROR, message, null, null, -1, + offset, 0); + } + + @Test + void testIsWellFormed_whenNothingWasRecorded_isTrue() + { + final ScanProblems problems = new ScanProblems(""); + + assertThat(problems.isWellFormed()).as("A scan without problems is well-formed.").isTrue(); + assertThat(problems.toList()).as("A scan without problems has none listed.").isEmpty(); + } + + @Test + void testAddInfoProblem_whenAdded_recordsAnInfoAndKeepsTheTextWellFormed() + { + final ScanProblems problems = new ScanProblems("stray"); + + problems.addInfoProblem(ProblemCode.TEXT_CONTENT_IGNORED, "The text is ignored.", 9, 5); + + assertThat(problems.toList()).as("The info problem must be recorded as given.") + .containsExactly(new DatasetProblem(ProblemCode.TEXT_CONTENT_IGNORED, ProblemSeverity.INFO, + "The text is ignored.", null, null, -1, 9, 5)); + assertThat(problems.isWellFormed()).as("An info problem does not block editing.").isTrue(); + } + + @Test + void testBlockingError_whenRecorded_addsTheLineAndColumnAndMarksTheTextNotWellFormed() + { + final ScanProblems problems = new ScanProblems("\n"); + + problems.blockingError(ProblemCode.NOT_WELL_FORMED, "Expected , but found .", 17); + + assertThat(problems.toList()).as("The message must end with the line and column of the offset.") + .containsExactly( + errorAt("Expected , but found . (line 2, column 8)", 17)); + assertThat(problems.isWellFormed()).as("A blocking problem makes the text not well-formed.") + .isFalse(); + } + + @Test + void testBlockingError_whenTheOffsetIsZero_reportsLineOneColumnOne() + { + final ScanProblems problems = new ScanProblems(""); + + problems.blockingError(ProblemCode.NOT_WELL_FORMED, "Problem.", 0); + + assertThat(problems.toList()).as("The first character is at line 1, column 1.") + .containsExactly(errorAt("Problem. (line 1, column 1)", 0)); + } + + @Test + void testBlockingError_whenLinesEndWithCarriageReturnAndLineFeed_countsEachPairOnce() + { + final ScanProblems problems = new ScanProblems("a\r\nb\r\nc"); + + problems.blockingError(ProblemCode.NOT_WELL_FORMED, "Problem.", 6); + + assertThat(problems.toList()).as("Two CR LF pairs must count as two line ends.") + .containsExactly(errorAt("Problem. (line 3, column 1)", 6)); + } + + @Test + void testBlockingError_whenLinesEndWithLineFeedOnly_countsEachOne() + { + final ScanProblems problems = new ScanProblems("a\nb\nc"); + + problems.blockingError(ProblemCode.NOT_WELL_FORMED, "Problem.", 4); + + assertThat(problems.toList()).as("Two line feeds must count as two line ends.") + .containsExactly(errorAt("Problem. (line 3, column 1)", 4)); + } + + @Test + void testBlockingError_whenLinesEndWithCarriageReturnOnly_countsEachOne() + { + final ScanProblems problems = new ScanProblems("a\rb\rc"); + + problems.blockingError(ProblemCode.NOT_WELL_FORMED, "Problem.", 4); + + assertThat(problems.toList()).as("Two carriage returns must count as two line ends.") + .containsExactly(errorAt("Problem. (line 3, column 1)", 4)); + } + + @Test + void testBlockingError_whenTheOffsetIsInTheMiddleOfALine_countsTheColumnFromOne() + { + final ScanProblems problems = new ScanProblems("\n "); + + problems.blockingError(ProblemCode.NOT_WELL_FORMED, "Problem.", 12); + + assertThat(problems.toList()).as("Offset 12 is the third character of line 2.") + .containsExactly(errorAt("Problem. (line 2, column 3)", 12)); + } + + @Test + void testBlockingError_whenTheOffsetIsBeyondTheText_countsToTheEndOfTheTextOnly() + { + final ScanProblems problems = new ScanProblems("ab"); + + problems.blockingError(ProblemCode.NOT_WELL_FORMED, "Problem.", 10); + + assertThat(problems.toList()) + .as("The location must stop at the end of the text, and the offset stays as given.") + .containsExactly(errorAt("Problem. (line 1, column 3)", 10)); + } + + @Test + void testBlockingError_whenACauseIsGiven_returnsAnExceptionWithThatCause() + { + final ScanProblems problems = new ScanProblems(""); + final IllegalStateException cause = new IllegalStateException("cause"); + + final StopScanException exception = + problems.blockingError(ProblemCode.NOT_WELL_FORMED, "Problem.", 0, cause); + + assertThat(exception.getCause()).as("The exception must carry the exception behind the problem.") + .isSameAs(cause); + } + + @Test + void testBlockingError_whenNoCauseIsGiven_returnsAnExceptionWithoutCause() + { + final ScanProblems problems = new ScanProblems(""); + + final StopScanException exception = + problems.blockingError(ProblemCode.NOT_WELL_FORMED, "Problem.", 0); + + assertThat(exception.getCause()).as("There is no exception behind the problem.").isNull(); + } + + @Test + void testToList_whenInfoAndBlockingProblemsWereRecorded_listsThemInTheOrderRecorded() + { + final ScanProblems problems = new ScanProblems("stray\n" + + "\n\n" + + "\n]>\n" + + "\n \n \n\n"; + final ParsedDataset parsed = ParsedDataset.of(text); + + final List edits = rename(parsed, INSENSITIVE, "USERS", "PEOPLE"); + + assertThat(parsed.apply(edits)) + .as("The comment and the other table, whose name only starts like the renamed one, must " + + "stay as they are.") + .isEqualTo("\n" + + "\n\n" + + "\n]>\n" + + "\n \n \n\n"); + } + + @Test + void testRenameEdits_whenTheDoctypeNamesAnExternalDtdOnly_changesNothingButTheElements() throws Exception + { + final String text = "\n" + TEXT; + final ParsedDataset parsed = ParsedDataset.of(text); + + final List edits = rename(parsed, INSENSITIVE, "USERS", "PEOPLE"); + + assertThat(parsed.apply(edits)) + .as("The editor does not change a DTD file, and the system identifier is no table name.") + .isEqualTo("\n" + TEXT.replace("USERS", "PEOPLE")); + } + + @Test + void testDeleteEdits_whenTheTableHasRows_removesTheirLines() throws Exception + { + final ParsedDataset parsed = ParsedDataset.of(TEXT); + + final List edits = delete(parsed, "USERS"); + + assertThat(parsed.apply(edits)).as("Both USERS rows and their lines must go.") + .isEqualTo("\n" + ORDER + "\n"); + } + + @Test + void testDeleteEdits_whenTheTableHasAMarkerElement_removesItToo() throws Exception + { + final String text = "\n \n \n \n\n"; + final ParsedDataset parsed = ParsedDataset.of(text); + + final List edits = delete(parsed, "USERS"); + + assertThat(parsed.apply(edits)).as("Every element of the table must go.") + .isEqualTo("\n" + ORDER + "\n"); + } + + @Test + void testDeleteEdits_whenElementsShareTheirLine_removesTheElementsAlone() throws Exception + { + final String text = "\n"; + final ParsedDataset parsed = ParsedDataset.of(text); + + final List edits = delete(parsed, "USERS"); + + assertThat(parsed.apply(edits)).as("Neighbors on the same line must stay.") + .isEqualTo("\n"); + } + + @Test + void testDeleteEdits_whenTheTableIsOnlyDeclared_throwsDatasetEditException() + { + final ParsedDataset parsed = ParsedDataset.withDtd("\n", + ""); + + assertThatThrownBy(() -> delete(parsed, "PETS")) + .as("A table without an element has nothing to delete.") + .isInstanceOf(DatasetEditException.class) + .hasMessage(NLS.bind(Messages.Edit_deleteTableIsDeclaredOnly, "PETS")); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/TextContentScannerTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/TextContentScannerTest.java new file mode 100644 index 00000000..29ad4027 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/TextContentScannerTest.java @@ -0,0 +1,305 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.stream.Stream; + +import org.dbunit.eclipse.dataset.core.Messages; +import org.dbunit.eclipse.dataset.core.model.DatasetProblem; +import org.dbunit.eclipse.dataset.core.model.ProblemCode; +import org.dbunit.eclipse.dataset.core.model.ProblemSeverity; +import org.eclipse.osgi.util.NLS; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Tests {@link TextContentScanner}: each text is skipped up to the next tag, and whether it held more than + * whitespace and what was left unread are asserted together. Text that XML 1.0 does not allow must record + * one blocking problem at the offending character or reference and stop the scan. + */ +class TextContentScannerTest +{ + private static final boolean ENTITIES_CANNOT_BE_DECLARED = false; + + private static final boolean ENTITIES_MAY_BE_DECLARED = true; + + private static final String CONTROL_CHARACTER = String.valueOf((char) 0x1); + + private static final String EMOJI = new String(Character.toChars(0x1F600)); + + private record Skip(boolean significant, String remainder) + { + } + + private static Skip skip(final String text, final boolean entitiesMayBeDeclared) + { + final ScanProblems problems = new ScanProblems(text); + final XmlLexer lexer = new XmlLexer(text, problems); + final TextContentScanner scanner = new TextContentScanner(lexer, problems); + final boolean significant = scanner.skip(entitiesMayBeDeclared); + final String remainder = text.substring(lexer.position()); + return new Skip(significant, remainder); + } + + private static List problemsAfterFailure(final String text, + final boolean entitiesMayBeDeclared) + { + final ScanProblems problems = new ScanProblems(text); + final XmlLexer lexer = new XmlLexer(text, problems); + final TextContentScanner scanner = new TextContentScanner(lexer, problems); + assertThatThrownBy(() -> scanner.skip(entitiesMayBeDeclared)).as("Malformed text must stop the scan.") + .isInstanceOf(StopScanException.class); + return problems.toList(); + } + + private static DatasetProblem problemInFirstLine(final String message, final int offset) + { + final int column = offset + 1; + final String located = NLS.bind(Messages.Parser_position, new Object[] { message, 1, column }); + return new DatasetProblem(ProblemCode.NOT_WELL_FORMED, ProblemSeverity.ERROR, located, null, null, -1, + offset, 0); + } + + private static Stream ampersandsThatStartNoReference() + { + return Stream.of(Arguments.of("R&D fixtures", 1), Arguments.of("R & D", 2), Arguments.of("&;", 0), + Arguments.of("&1;", 0), Arguments.of("&", 0), Arguments.of("&#", 0), + Arguments.of("& more", 0), Arguments.of("x &", 2), Arguments.of("A more", 0), + Arguments.of("&a b;", 0)); + } + + private static Stream characterReferencesWithAnInvalidDigit() + { + return Stream.of(Arguments.of(" a;", "12a"), Arguments.of("A", "X41"), + Arguments.of("&#xZZ;", "ZZ"), Arguments.of("G;", "4G")); + } + + private static Stream characterReferencesToCharactersThatXmlDoesNotAllow() + { + return Stream.of(Arguments.of("", "1"), Arguments.of("�", "0"), Arguments.of(" ", "B"), + Arguments.of(" ", "C"), Arguments.of("�", "D800"), + Arguments.of("￾", "FFFE"), Arguments.of("￿", "FFFF")); + } + + @Test + void testSkip_whenTheTextIsOnlyWhitespace_isNotSignificantAndStopsAtTheNextTag() + { + final Skip result = skip("\r\n\t ", ENTITIES_CANNOT_BE_DECLARED); + + assertThat(result).as("Whitespace is skipped, and the tag that ends the text is left.") + .isEqualTo(new Skip(false, "")); + } + + @Test + void testSkip_whenATagIsNext_skipsNothing() + { + final Skip result = skip("", ENTITIES_CANNOT_BE_DECLARED); + + assertThat(result).as("There is no text before the tag.").isEqualTo(new Skip(false, "")); + } + + @Test + void testSkip_whenTheTextIsEmpty_skipsNothing() + { + final Skip result = skip("", ENTITIES_CANNOT_BE_DECLARED); + + assertThat(result).as("An empty text has no content.").isEqualTo(new Skip(false, "")); + } + + @Test + void testSkip_whenTheTextHoldsWords_isSignificantAndStopsAtTheNextTag() + { + final Skip result = skip(" stray text\n", ENTITIES_CANNOT_BE_DECLARED); + + assertThat(result).as("Words are content, and the tag that ends the text is left.") + .isEqualTo(new Skip(true, "")); + } + + @Test + void testSkip_whenTheTextRunsToTheEnd_stopsAtTheEnd() + { + final Skip result = skip("stray text", ENTITIES_CANNOT_BE_DECLARED); + + assertThat(result).as("The text ends where the document does.").isEqualTo(new Skip(true, "")); + } + + @Test + void testSkip_whenTheTextHoldsGreaterThanSignsAndCloseBrackets_acceptsThem() + { + final Skip result = skip("a > b ]] > c ] d ]>", ENTITIES_CANNOT_BE_DECLARED); + + assertThat(result).as("Only the three characters ]]> in a row are forbidden in text.") + .isEqualTo(new Skip(true, "")); + } + + @ParameterizedTest + @ValueSource(strings = { "&", "<", ">", """, "'" }) + void testSkip_whenTheTextHoldsAPredefinedEntityReference_acceptsItWithoutADtd(final String reference) + { + final Skip result = skip(reference + "<", ENTITIES_CANNOT_BE_DECLARED); + + assertThat(result).as("The five predefined entities need no declaration.") + .isEqualTo(new Skip(true, "<")); + } + + @ParameterizedTest + @ValueSource(strings = { "A", "A", "A", " ", " ", " ", "😀", "﷐", + "…" }) + void testSkip_whenTheTextHoldsACharacterReference_acceptsIt(final String reference) + { + final Skip result = skip(reference + "<", ENTITIES_CANNOT_BE_DECLARED); + + assertThat(result).as("A reference to an XML character is well-formed.") + .isEqualTo(new Skip(true, "<")); + } + + @Test + void testSkip_whenTheTextHoldsSeveralReferences_acceptsThemAll() + { + final Skip result = skip("R&D <b> AB<", ENTITIES_CANNOT_BE_DECLARED); + + assertThat(result).as("Every reference of a text is checked, and the text goes on after each one.") + .isEqualTo(new Skip(true, "<")); + } + + @Test + void testSkip_whenTheTextHoldsCharactersThatXmlAllowsButDiscourages_acceptsThem() + { + final String text = "a" + EMOJI + (char) 0x7F + (char) 0x85 + (char) 0xFDD0 + "z"; + + final Skip result = skip(text, ENTITIES_CANNOT_BE_DECLARED); + + assertThat(result).as("A supplementary character, DEL, NEL, and a noncharacter are XML characters.") + .isEqualTo(new Skip(true, "")); + } + + @Test + void testSkip_whenEntitiesMayBeDeclared_acceptsAReferenceToAnyEntity() + { + final Skip result = skip("a   b<", ENTITIES_MAY_BE_DECLARED); + + assertThat(result).as("A DTD may declare the entity, so the reference is accepted.") + .isEqualTo(new Skip(true, "<")); + } + + @ParameterizedTest + @MethodSource("ampersandsThatStartNoReference") + void testSkip_whenAnAmpersandStartsNoReference_recordsABlockingProblemAtIt(final String text, + final int offset) + { + final List problems = problemsAfterFailure(text, ENTITIES_MAY_BE_DECLARED); + + assertThat(problems).as("A bare ampersand must be reported at the ampersand.") + .containsExactly(problemInFirstLine(Messages.Codec_invalidAmpersand, offset)); + } + + @ParameterizedTest + @ValueSource(strings = { "&#;", "&#x;" }) + void testSkip_whenACharacterReferenceHasNoDigits_recordsABlockingProblemAtTheAmpersand(final String text) + { + final List problems = problemsAfterFailure(text, ENTITIES_MAY_BE_DECLARED); + + assertThat(problems).as("A character reference needs at least one digit.") + .containsExactly(problemInFirstLine(Messages.Codec_referenceWithoutDigits, 0)); + } + + @ParameterizedTest + @MethodSource("characterReferencesWithAnInvalidDigit") + void testSkip_whenACharacterReferenceHasAnInvalidDigit_recordsABlockingProblemAtTheAmpersand( + final String text, final String digits) + { + final List problems = problemsAfterFailure(text, ENTITIES_MAY_BE_DECLARED); + + assertThat(problems).as("A digit that does not fit the radix must be reported.") + .containsExactly(problemInFirstLine(NLS.bind(Messages.Codec_invalidReference, digits), 0)); + } + + @ParameterizedTest + @MethodSource("characterReferencesToCharactersThatXmlDoesNotAllow") + void testSkip_whenACharacterReferenceNamesNoXmlCharacter_recordsABlockingProblemAtTheAmpersand( + final String text, final String hexadecimal) + { + final List problems = problemsAfterFailure(text, ENTITIES_MAY_BE_DECLARED); + + assertThat(problems).as("A reference to a character that XML forbids must be reported.") + .containsExactly(problemInFirstLine( + NLS.bind(Messages.Codec_referenceNotXmlCharacter, hexadecimal), 0)); + } + + @ParameterizedTest + @ValueSource(strings = { "�", "�" }) + void testSkip_whenACharacterReferenceIsTooLarge_recordsABlockingProblemAtTheAmpersand(final String text) + { + final List problems = problemsAfterFailure(text, ENTITIES_MAY_BE_DECLARED); + + assertThat(problems).as("A reference beyond U+10FFFF must be reported.") + .containsExactly(problemInFirstLine(Messages.Codec_referenceTooLarge, 0)); + } + + @Test + void testSkip_whenTheTextHoldsACdataEnd_recordsABlockingProblemAtIt() + { + final List problems = problemsAfterFailure("a ]]> b", ENTITIES_MAY_BE_DECLARED); + + assertThat(problems).as("The three characters ]]> must be reported where they start.") + .containsExactly(problemInFirstLine(Messages.Parser_cdataEndInText, 2)); + } + + @Test + void testSkip_whenTheTextHoldsAControlCharacter_recordsABlockingProblemAtIt() + { + final List problems = + problemsAfterFailure(" a" + CONTROL_CHARACTER + "b", ENTITIES_MAY_BE_DECLARED); + + assertThat(problems).as("A character that XML forbids must be reported where it is.") + .containsExactly(problemInFirstLine(NLS.bind(Messages.Codec_notXmlCharacter, "0001"), 3)); + } + + @Test + void testSkip_whenTheTextHoldsALoneSurrogate_recordsABlockingProblemAtIt() + { + final List problems = + problemsAfterFailure("a" + (char) 0xD800 + "b", ENTITIES_MAY_BE_DECLARED); + + assertThat(problems).as("A surrogate without its partner is no XML character.") + .containsExactly(problemInFirstLine(NLS.bind(Messages.Codec_notXmlCharacter, "D800"), 1)); + } + + @ParameterizedTest + @ValueSource(strings = { " ", "a © b", "&", "≪" }) + void testSkip_whenEntitiesCannotBeDeclared_recordsABlockingProblemForAnyOtherEntity(final String text) + { + final List problems = problemsAfterFailure(text, ENTITIES_CANNOT_BE_DECLARED); + + final int offset = text.indexOf('&'); + final String name = text.substring(offset + 1, text.indexOf(';')); + assertThat(problems).as("An entity that nothing declares must be reported at its ampersand.") + .containsExactly( + problemInFirstLine(NLS.bind(Messages.Parser_entityNotDeclared, name), offset)); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/TextEditApplierTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/TextEditApplierTest.java new file mode 100644 index 00000000..de9e8247 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/TextEditApplierTest.java @@ -0,0 +1,318 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.jface.text.Document; +import org.eclipse.jface.text.DocumentEvent; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.IDocumentListener; +import org.eclipse.text.edits.DeleteEdit; +import org.eclipse.text.edits.InsertEdit; +import org.eclipse.text.edits.MalformedTreeException; +import org.eclipse.text.edits.MultiTextEdit; +import org.eclipse.text.edits.ReplaceEdit; +import org.eclipse.text.edits.TextEdit; +import org.eclipse.text.undo.DocumentUndoManagerRegistry; +import org.eclipse.text.undo.IDocumentUndoManager; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link TextEditApplier} on plain {@link Document}s: how edits are applied, joined, and rejected, + * and how each call becomes a step of the undo history. + */ +class TextEditApplierTest +{ + private static final String DIGITS = "0123456789"; + + private static final String LONG_TEXT = + "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz"; + + @FunctionalInterface + private interface UndoManagerConsumer + { + void accept(IDocumentUndoManager undoManager) throws Exception; + } + + private static final class DocumentChangeCounter implements IDocumentListener + { + private int count; + + private final List replacedLengths = new ArrayList<>(); + + @Override + public void documentAboutToBeChanged(final DocumentEvent event) + { + // Only the changes themselves are counted. + } + + @Override + public void documentChanged(final DocumentEvent event) + { + count++; + replacedLengths.add(event.getLength()); + } + } + + private static void withUndoManager(final IDocument document, final UndoManagerConsumer consumer) + throws Exception + { + DocumentUndoManagerRegistry.connect(document); + final IDocumentUndoManager undoManager = DocumentUndoManagerRegistry.getDocumentUndoManager(document); + undoManager.connect(TextEditApplierTest.class); + try + { + consumer.accept(undoManager); + } + finally + { + undoManager.disconnect(TextEditApplierTest.class); + DocumentUndoManagerRegistry.disconnect(document); + } + } + + private static List insertsAtEveryOffsetFromZeroTo(final int lastOffset) + { + final List edits = new ArrayList<>(); + for (int offset = 0; offset <= lastOffset; offset++) + { + edits.add(new InsertEdit(offset, "-")); + } + return edits; + } + + private static String textWithADashBeforeEachOffsetFromZeroTo(final String text, final int lastOffset) + { + final StringBuilder expected = new StringBuilder(); + for (int offset = 0; offset < text.length(); offset++) + { + if (offset <= lastOffset) + { + expected.append('-'); + } + expected.append(text.charAt(offset)); + } + return expected.toString(); + } + + @Test + void testApply_whenGivenOneEdit_changesTheText() + { + final IDocument document = new Document(DIGITS); + + new TextEditApplier().apply(document, List.of(new ReplaceEdit(1, 2, "XY"))); + + assertThat(document.get()).as("The replaced range must hold the new text.").isEqualTo("0XY3456789"); + } + + @Test + void testApply_whenGivenEditsOfEveryKind_appliesAllOfThemAgainstTheOriginalOffsets() + { + final IDocument document = new Document(DIGITS); + final List edits = + List.of(new InsertEdit(0, "<"), new ReplaceEdit(2, 2, "__"), new DeleteEdit(6, 2)); + + new TextEditApplier().apply(document, edits); + + assertThat(document.get()).as("Each edit must use the offsets of the text before any edit.") + .isEqualTo("<01__4589"); + } + + @Test + void testApply_whenGivenExactlyFiftyEdits_appliesAllOfThem() + { + final String text = LONG_TEXT; + final IDocument document = new Document(text); + + new TextEditApplier().apply(document, insertsAtEveryOffsetFromZeroTo(49)); + + assertThat(document.get()).as("Fifty edits are still applied one by one, with the same result.") + .isEqualTo(textWithADashBeforeEachOffsetFromZeroTo(text, 49)); + } + + @Test + void testApply_whenGivenMoreThanFiftyEdits_changesTheDocumentOnce() + { + final String text = LONG_TEXT; + final IDocument document = new Document(text); + final DocumentChangeCounter changes = new DocumentChangeCounter(); + document.addDocumentListener(changes); + + new TextEditApplier().apply(document, insertsAtEveryOffsetFromZeroTo(50)); + + assertThat(document.get()).as("Joining the edits must not change the result.") + .isEqualTo(textWithADashBeforeEachOffsetFromZeroTo(text, 50)); + assertThat(changes.count).as("More than fifty edits must change the document once.").isEqualTo(1); + } + + @Test + void testApply_whenMoreThanFiftyEditsAreFarApart_changesOnlyTheTextOfEachRunOfNearbyEdits() + { + final String text = "a".repeat(300_000); + final IDocument document = new Document(text); + final DocumentChangeCounter changes = new DocumentChangeCounter(); + document.addDocumentListener(changes); + final List edits = new ArrayList<>(); + final StringBuilder expected = new StringBuilder(text); + for (int offset = 250_050; offset >= 250_000; offset--) + { + expected.insert(offset, '-'); + } + for (int offset = 50; offset >= 0; offset--) + { + expected.insert(offset, '-'); + } + for (int offset = 0; offset <= 50; offset++) + { + edits.add(new InsertEdit(offset, "-")); + edits.add(new InsertEdit(250_000 + offset, "-")); + } + + new TextEditApplier().apply(document, edits); + + assertThat(document.get()).as("Splitting the edits into runs must not change the result.") + .isEqualTo(expected.toString()); + assertThat(changes.replacedLengths) + .as("The two runs of edits must each replace a short span, not the 250000 characters " + + "between them.") + .hasSize(2).allMatch(length -> length <= 60); + } + + @Test + void testApply_whenMoreThanFiftyEditsAreSeparatedByAShortGap_changesTheDocumentOnce() + { + final String text = "a".repeat(100_000); + final IDocument document = new Document(text); + final DocumentChangeCounter changes = new DocumentChangeCounter(); + document.addDocumentListener(changes); + final List edits = new ArrayList<>(); + for (int index = 0; index <= 50; index++) + { + edits.add(new InsertEdit(index * 1_000, "-")); + } + + new TextEditApplier().apply(document, edits); + + assertThat(changes.count).as("Edits that are close together are joined into one change.") + .isEqualTo(1); + } + + @Test + void testApply_whenJoinedEditsOverlap_throwsIllegalStateException() + { + final IDocument document = new Document(LONG_TEXT); + final List edits = new ArrayList<>(); + edits.add(new ReplaceEdit(0, 5, "x")); + edits.add(new ReplaceEdit(3, 4, "y")); + for (int offset = 10; offset < 60; offset++) + { + edits.add(new InsertEdit(offset, "-")); + } + + assertThatThrownBy(() -> new TextEditApplier().apply(document, edits)) + .as("Joining overlapping edits must be refused.").isInstanceOf(IllegalStateException.class) + .hasMessage("Computed text edits overlap."); + } + + @Test + void testApply_whenFewEditsOverlap_throwsMalformedTreeException() + { + final IDocument document = new Document(DIGITS); + final List edits = List.of(new ReplaceEdit(0, 5, "x"), new ReplaceEdit(3, 4, "y")); + + assertThatThrownBy(() -> new TextEditApplier().apply(document, edits)) + .as("Combining overlapping edits must be refused by the edit tree.") + .isInstanceOf(MalformedTreeException.class); + } + + @Test + void testApply_whenAnEditIsBeyondTheEndOfTheDocument_throwsIllegalStateException() + { + final IDocument document = new Document(DIGITS); + + assertThatThrownBy(() -> new TextEditApplier().apply(document, List.of(new InsertEdit(100, "x")))) + .as("An edit outside the document must be reported as one that does not fit.") + .isInstanceOf(IllegalStateException.class) + .hasMessage("Computed text edits do not fit the document."); + } + + @Test + void testApply_whenJoinedEditsAreBeyondTheEndOfTheDocument_throwsIllegalStateException() + { + final IDocument document = new Document(DIGITS); + + assertThatThrownBy(() -> new TextEditApplier().apply(document, insertsAtEveryOffsetFromZeroTo(60))) + .as("Joined edits outside the document must be reported as ones that do not fit.") + .isInstanceOf(IllegalStateException.class) + .hasMessage("Computed text edits do not fit the document."); + } + + @Test + void testApply_whenJoinedEditsHoldAnUnsupportedKind_throwsIllegalStateException() + { + final IDocument document = new Document(LONG_TEXT); + final List edits = new ArrayList<>(insertsAtEveryOffsetFromZeroTo(50)); + edits.add(new MultiTextEdit(0, 0)); + + assertThatThrownBy(() -> new TextEditApplier().apply(document, edits)) + .as("Only replace, insert, and delete edits can be joined.") + .isInstanceOf(IllegalStateException.class) + .hasMessage("Unsupported text edit org.eclipse.text.edits.MultiTextEdit."); + } + + @Test + void testApply_whenCalledTwice_isOneUndoStepPerCall() throws Exception + { + final IDocument document = new Document(DIGITS); + withUndoManager(document, undoManager -> + { + final TextEditApplier applier = new TextEditApplier(); + applier.apply(document, List.of(new InsertEdit(0, "a"))); + applier.apply(document, List.of(new InsertEdit(0, "b"))); + + undoManager.undo(); + + assertThat(document.get()).as("One undo must revert only the last call.") + .isEqualTo("a" + DIGITS); + }); + } + + @Test + void testApply_whenSeveralEditsAreGiven_isOneUndoStep() throws Exception + { + final IDocument document = new Document(DIGITS); + withUndoManager(document, undoManager -> + { + new TextEditApplier().apply(document, + List.of(new InsertEdit(0, "<"), new ReplaceEdit(2, 2, "__"), new DeleteEdit(6, 2))); + + undoManager.undo(); + + assertThat(document.get()).as("One undo must revert all the edits of a call.").isEqualTo(DIGITS); + }); + } + +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/XmlLexerTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/XmlLexerTest.java new file mode 100644 index 00000000..0aa9b55e --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/XmlLexerTest.java @@ -0,0 +1,853 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.dbunit.eclipse.dataset.core.Messages; +import org.dbunit.eclipse.dataset.core.model.DatasetProblem; +import org.dbunit.eclipse.dataset.core.model.ProblemCode; +import org.dbunit.eclipse.dataset.core.model.ProblemSeverity; +import org.eclipse.osgi.util.NLS; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link XmlLexer}: each operation is run on a small text, and the token it returns and the text left + * unread are asserted together. A malformed text must record one blocking problem and stop the scan. + */ +class XmlLexerTest +{ + private static final String CONTROL_CHARACTER = String.valueOf((char) 0x1); + + private static final String LONE_SURROGATE = String.valueOf((char) 0xD800); + + private static final String EMOJI = new String(Character.toChars(0x1F600)); + + private record Scan(String token, String remainder) + { + } + + private static XmlLexer lexerFor(final String text) + { + return new XmlLexer(text, new ScanProblems(text)); + } + + private static String remainderAfter(final String text, final Consumer operation) + { + final XmlLexer lexer = lexerFor(text); + operation.accept(lexer); + final int position = lexer.position(); + return text.substring(position); + } + + private static String remainderAfterSkippingALiteral(final String text) + { + return remainderAfter(text, lexer -> lexer.skipQuotedLiteral("unclosed")); + } + + private static Scan scan(final String text, final Function operation) + { + final XmlLexer lexer = lexerFor(text); + final String token = operation.apply(lexer); + final int position = lexer.position(); + final String remainder = text.substring(position); + return new Scan(token, remainder); + } + + private static List problemsAfterFailure(final String text, final int start, + final Consumer operation) + { + final ScanProblems problems = new ScanProblems(text); + final XmlLexer lexer = new XmlLexer(text, problems); + lexer.advance(start); + assertThatThrownBy(() -> operation.accept(lexer)).as("A malformed text must stop the scan.") + .isInstanceOf(StopScanException.class); + return problems.toList(); + } + + private static DatasetProblem problemInFirstLine(final String message, final int offset) + { + final int column = offset + 1; + final String located = NLS.bind(Messages.Parser_position, new Object[] { message, 1, column }); + return new DatasetProblem(ProblemCode.NOT_WELL_FORMED, ProblemSeverity.ERROR, located, null, null, -1, + offset, 0); + } + + @Test + void testPosition_whenNewlyCreated_isZero() + { + final XmlLexer lexer = lexerFor(""); + + assertThat(lexer.position()).as("A new lexer starts at the first character.").isZero(); + } + + @Test + void testAdvance_whenCalled_movesThePositionForward() + { + final XmlLexer lexer = lexerFor("abcdef"); + + lexer.advance(2); + + assertThat(lexer.position()).as("The position must move over the given number of characters.") + .isEqualTo(2); + assertThat(lexer.currentCharacter()).as("The next character must be the one after those skipped.") + .isEqualTo('c'); + } + + @Test + void testAtEnd_whenTheTextIsEmpty_isTrue() + { + final XmlLexer lexer = lexerFor(""); + + assertThat(lexer.atEnd()).as("An empty text has nothing left to read.").isTrue(); + } + + @Test + void testAtEnd_whenCharactersRemain_isFalse() + { + final XmlLexer lexer = lexerFor("a"); + + assertThat(lexer.atEnd()).as("A character remains to be read.").isFalse(); + } + + @Test + void testAtCharacter_whenTheCharacterIsNext_isTrue() + { + final XmlLexer lexer = lexerFor(""); + + assertThat(lexer.atCharacter('<')).as("The opening angle bracket is next.").isTrue(); + } + + @Test + void testAtCharacter_whenAnotherCharacterIsNext_isFalse() + { + final XmlLexer lexer = lexerFor(""); + + assertThat(lexer.atCharacter('a')).as("A different character is next.").isFalse(); + } + + @Test + void testAtCharacter_whenAtTheEnd_isFalse() + { + final XmlLexer lexer = lexerFor(""); + + assertThat(lexer.atCharacter('a')).as("No character is next at the end of the text.").isFalse(); + } + + @Test + void testAtText_whenTheLiteralStartsAtThePosition_isTrue() + { + final XmlLexer lexer = lexerFor("xx"); + lexer.advance(2); + + assertThat(lexer.atText(" tail", XmlLexer::skipComment); + + assertThat(remainder).as("The whole comment, including a greater-than sign, must be skipped.") + .isEqualTo(" tail"); + } + + @Test + void testSkipComment_whenUnclosed_recordsABlockingProblemAtItsStart() + { + final List problems = problemsAfterFailure("ab", 0, XmlLexer::skipComment); + + assertThat(problems).as("Two hyphens in a row are not allowed in a comment.") + .containsExactly(problemInFirstLine(Messages.Parser_doubleHyphenInComment, 7)); + } + + @Test + void testSkipComment_whenItEndsWithThreeHyphens_recordsABlockingProblemAtTheFirstOfThem() + { + final List problems = + problemsAfterFailure("", 0, XmlLexer::skipComment); + + assertThat(problems).as("A comment cannot end with a hyphen right before its closing marker.") + .containsExactly(problemInFirstLine(Messages.Parser_doubleHyphenInComment, 7)); + } + + @Test + void testSkipComment_whenItHoldsSingleHyphensOrNothing_stopsAfterTheClosingMarker() + { + final String withHyphens = remainderAfter(" tail", XmlLexer::skipComment); + final String empty = remainderAfter(" tail", XmlLexer::skipComment); + + assertThat(List.of(withHyphens, empty)) + .as("Hyphens that are not doubled and an empty comment are well-formed.") + .containsExactly(" tail", " tail"); + } + + @Test + void testSkipProcessingInstruction_whenTheTargetIsXmlAfterAByteOrderMark_stopsAfterTheClosingMarker() + { + final String remainder = remainderAfter("\uFEFF tail", lexer -> + { + lexer.skipBom(); + lexer.skipProcessingInstruction(); + }); + + assertThat(remainder).as("A byte order mark does not move the start of the text.") + .isEqualTo(" tail"); + } + + @Test + void testSkipProcessingInstruction_whenTheTargetIsXmlAfterTheStart_recordsABlockingProblemAtTheTarget() + { + final List problems = problemsAfterFailure("ab", 2, + XmlLexer::skipProcessingInstruction); + + assertThat(problems).as("The XML declaration can only start the text.") + .containsExactly(problemInFirstLine( + NLS.bind(Messages.Parser_reservedProcessingInstructionTarget, "xml"), 4)); + } + + @Test + void testSkipProcessingInstruction_whenTheTargetIsXmlInCapitals_recordsABlockingProblemAtTheTarget() + { + final List problems = + problemsAfterFailure("", 0, XmlLexer::skipProcessingInstruction); + + assertThat(problems).as("Only the lowercase xml names the XML declaration.") + .containsExactly(problemInFirstLine( + NLS.bind(Messages.Parser_reservedProcessingInstructionTarget, "XML"), 2)); + } + + @Test + void testSkipProcessingInstruction_whenTheTargetOnlyStartsWithXml_stopsAfterTheClosingMarker() + { + final String remainder = remainderAfter("ab tail", lexer -> + { + lexer.advance(2); + lexer.skipProcessingInstruction(); + }); + + assertThat(remainder).as("A longer target is not reserved.").isEqualTo(" tail"); + } + + @Test + void testSkipProcessingInstruction_whenThereIsNoTarget_recordsABlockingProblemWhereItShouldBe() + { + final List problems = + problemsAfterFailure("", 0, XmlLexer::skipProcessingInstruction); + + assertThat(problems).as("The target must follow the opening marker at once.") + .containsExactly(problemInFirstLine(Messages.Parser_expectedProcessingInstructionTarget, 2)); + } + + @Test + void testSkipProcessingInstruction_whenTheTargetIsNotFollowedByWhitespace_recordsABlockingProblemAfterIt() + { + final List problems = + problemsAfterFailure("", 0, XmlLexer::skipProcessingInstruction); + + assertThat(problems).as("Whitespace or the end marker must follow the target.") + .containsExactly( + problemInFirstLine(Messages.Parser_expectedSpaceAfterProcessingInstructionTarget, 8)); + } + + @Test + void testSkipProcessingInstruction_whenTheTargetEndsTheInstructionOrIsFollowedByData_stopsAfterIt() + { + final String withoutData = remainderAfter(" tail", XmlLexer::skipProcessingInstruction); + final String withData = remainderAfter(" tail", XmlLexer::skipProcessingInstruction); + + assertThat(List.of(withoutData, withData)) + .as("A target with or without data, and a target of hyphens, dots, and digits, are valid.") + .containsExactly(" tail", " tail"); + } + + @Test + void testSkipProcessingInstruction_whenTheTargetIsAtTheEndOfTheText_recordsTheUnclosedInstruction() + { + final List problems = + problemsAfterFailure("ab tail", XmlLexer::skipCData); + + assertThat(remainder).as("The whole CDATA section, including a less-than sign, must be skipped.") + .isEqualTo(" tail"); + } + + @Test + void testSkipCData_whenUnclosed_recordsABlockingProblemAtItsStart() + { + final List problems = + problemsAfterFailure("ab"; + + final List problems = + problemsAfterFailure(text, 0, XmlLexer::skipProcessingInstruction); + + assertThat(problems).as("A control character must be reported where it is.") + .containsExactly(problemInFirstLine(NLS.bind(Messages.Codec_notXmlCharacter, "0001"), 10)); + } + + @Test + void testSkipComment_whenItHoldsACharacterThatXmlDoesNotAllow_recordsABlockingProblemAtIt() + { + final String text = ""; + + final List problems = problemsAfterFailure(text, 0, XmlLexer::skipComment); + + assertThat(problems).as("A control character must be reported where it is.") + .containsExactly(problemInFirstLine(NLS.bind(Messages.Codec_notXmlCharacter, "0001"), 6)); + } + + @Test + void testSkipComment_whenItHoldsALoneSurrogate_recordsABlockingProblemAtIt() + { + final String text = ""; + + final List problems = problemsAfterFailure(text, 0, XmlLexer::skipComment); + + assertThat(problems).as("A surrogate without its partner is no XML character.") + .containsExactly(problemInFirstLine(NLS.bind(Messages.Codec_notXmlCharacter, "D800"), 4)); + } + + @Test + void testSkipComment_whenItHoldsASupplementaryCharacterAndAmpersands_stopsAfterTheClosingMarker() + { + final String remainder = remainderAfter(" tail", XmlLexer::skipComment); + + assertThat(remainder).as("Text that is no markup inside a comment must be skipped as it is.") + .isEqualTo(" tail"); + } + + @Test + void testSkipCData_whenItHoldsACharacterThatXmlDoesNotAllow_recordsABlockingProblemAtIt() + { + final String text = ""; + + final List problems = problemsAfterFailure(text, 0, XmlLexer::skipCData); + + assertThat(problems).as("A control character must be reported where it is.") + .containsExactly(problemInFirstLine(NLS.bind(Messages.Codec_notXmlCharacter, "0001"), 10)); + } + + @Test + void testSkipCData_whenItHoldsAmpersandsAndASupplementaryCharacter_stopsAfterTheClosingMarker() + { + final String remainder = + remainderAfter(" tail", XmlLexer::skipCData); + + assertThat(remainder).as("References inside a CDATA section are plain text.").isEqualTo(" tail"); + } + + @Test + void testSkipXmlCharacter_whenAnOrdinaryCharacterIsNext_movesOverIt() + { + final String remainder = remainderAfter("ab", XmlLexer::skipXmlCharacter); + + assertThat(remainder).as("One character must be skipped.").isEqualTo("b"); + } + + @Test + void testSkipXmlCharacter_whenASurrogatePairIsNext_movesOverBothHalves() + { + final String remainder = remainderAfter(EMOJI + "b", XmlLexer::skipXmlCharacter); + + assertThat(remainder).as("A surrogate pair is one character.").isEqualTo("b"); + } + + @Test + void testSkipXmlCharacter_whenTheCharacterIsALineFeedOrTab_movesOverIt() + { + final List remainders = List.of(remainderAfter("\nb", XmlLexer::skipXmlCharacter), + remainderAfter("\tb", XmlLexer::skipXmlCharacter)); + + assertThat(remainders).as("Tab and line feed are XML characters.").containsExactly("b", "b"); + } + + @Test + void testSkipXmlCharacter_whenTheCharacterIsANoncharacterWithinTheXmlRange_movesOverIt() + { + final String remainder = remainderAfter((char) 0xFDD0 + "b", XmlLexer::skipXmlCharacter); + + assertThat(remainder).as("XML 1.0 allows U+FDD0, as it allows every character up to U+FFFD.") + .isEqualTo("b"); + } + + @Test + void testSkipXmlCharacter_whenTheCharacterIsAControlCharacter_recordsABlockingProblemAtIt() + { + final List problems = + problemsAfterFailure("a" + CONTROL_CHARACTER, 1, XmlLexer::skipXmlCharacter); + + assertThat(problems).as("A control character is no XML character and must be reported.") + .containsExactly(problemInFirstLine(NLS.bind(Messages.Codec_notXmlCharacter, "0001"), 1)); + } + + @Test + void testSkipXmlCharacter_whenTheCharacterIsOutsideTheXmlRange_recordsABlockingProblemAtIt() + { + final List problems = + problemsAfterFailure(String.valueOf((char) 0xFFFE), 0, XmlLexer::skipXmlCharacter); + + assertThat(problems).as("U+FFFE is outside the XML range and must be reported.") + .containsExactly(problemInFirstLine(NLS.bind(Messages.Codec_notXmlCharacter, "FFFE"), 0)); + } + + @Test + void testScanName_whenTheNameIsFollowedByWhitespace_stopsAtTheWhitespace() + { + final Scan scan = scan("USERS ID", XmlLexer::scanName); + + assertThat(scan).as("The name must be returned and the whitespace left unread.") + .isEqualTo(new Scan("USERS", " ID")); + } + + @Test + void testScanName_whenTheNameHasPunctuation_keepsTheNameCharacters() + { + final Scan scan = scan("a-b.c:d>", XmlLexer::scanName); + + assertThat(scan).as("Hyphens, dots, and colons are name characters.") + .isEqualTo(new Scan("a-b.c:d", ">")); + } + + @Test + void testScanName_whenASupplementaryCharacterFollowsTheName_stopsBeforeIt() + { + final String supplementary = new String(Character.toChars(0x10000)); + final Scan scan = scan("a" + supplementary + "b c", XmlLexer::scanName); + + assertThat(scan).as("The parser takes no character beyond U+FFFF in a name, so the name ends before " + + "it, whole, not in the middle of its surrogate pair.") + .isEqualTo(new Scan("a", supplementary + "b c")); + } + + @Test + void testScanName_whenTheSameNameIsScannedTwice_returnsOneSharedString() + { + final String text = "USERS USERS"; + final XmlLexer lexer = lexerFor(text); + final String first = lexer.scanName(); + lexer.skipWhitespace(); + + final String second = lexer.scanName(); + + assertThat(second).as("Repeated names must share one string instead of one per occurrence.") + .isSameAs(first); + } + + @Test + void testScanName_whenTheFirstCharacterCannotStartAName_recordsABlockingProblem() + { + final List problems = problemsAfterFailure("<1abc", 1, XmlLexer::scanName); + + assertThat(problems).as("A name that starts with a digit must be reported at that digit.") + .containsExactly(problemInFirstLine(Messages.Parser_expectedName, 1)); + } + + @Test + void testScanName_whenAtTheEnd_recordsABlockingProblem() + { + final List problems = problemsAfterFailure("<", 1, XmlLexer::scanName); + + assertThat(problems).as("A name is required at the end of the text too.") + .containsExactly(problemInFirstLine(Messages.Parser_expectedName, 1)); + } + + @Test + void testScanQuotedLiteral_whenDoubleQuoted_returnsTheValue() + { + final Scan scan = scan("\"my.dtd\" tail", XmlLexer::scanQuotedLiteral); + + assertThat(scan).as("The value without its quotes must be returned.") + .isEqualTo(new Scan("my.dtd", " tail")); + } + + @Test + void testScanQuotedLiteral_whenSingleQuoted_returnsTheValueWithTheOtherQuoteInside() + { + final Scan scan = scan("'a\"b' tail", XmlLexer::scanQuotedLiteral); + + assertThat(scan).as("A double quote inside a single-quoted literal must not end it.") + .isEqualTo(new Scan("a\"b", " tail")); + } + + @Test + void testSkipQuotedLiteral_whenDoubleQuoted_movesPastTheClosingQuote() + { + final String remainder = remainderAfterSkippingALiteral("\"a>b]c\" tail"); + + assertThat(remainder).as("Everything up to and including the closing quote must be skipped, '>' and " + + "']' included.").isEqualTo(" tail"); + } + + @Test + void testSkipQuotedLiteral_whenSingleQuoted_isNotEndedByADoubleQuote() + { + final String remainder = remainderAfterSkippingALiteral("'a\"b' tail"); + + assertThat(remainder).as("A double quote inside a single-quoted literal must not end it.") + .isEqualTo(" tail"); + } + + @Test + void testSkipQuotedLiteral_whenTheLiteralHoldsLineBreaks_movesPastThem() + { + final String remainder = remainderAfterSkippingALiteral("\"a\nb\r\nc\"x"); + + assertThat(remainder).as("A literal may span lines.").isEqualTo("x"); + } + + @Test + void testSkipQuotedLiteral_whenUnclosed_recordsTheGivenMessageAtTheOpeningQuote() + { + final List problems = + problemsAfterFailure("ab\"open", 2, lexer -> lexer.skipQuotedLiteral("my message")); + + assertThat(problems).as("An unclosed literal must be reported at its opening quote, with the " + + "message that the caller gave.") + .containsExactly(problemInFirstLine("my message", 2)); + } + + @Test + void testScanQuotedLiteral_whenNoQuoteIsNext_recordsABlockingProblem() + { + final List problems = problemsAfterFailure("ab", 0, XmlLexer::scanQuotedLiteral); + + assertThat(problems).as("A literal must start with a quote.") + .containsExactly(problemInFirstLine(Messages.Parser_expectedQuotedLiteral, 0)); + } + + @Test + void testScanQuotedLiteral_whenUnclosed_recordsABlockingProblemAtTheOpeningQuote() + { + final List problems = + problemsAfterFailure("ab\"open", 2, XmlLexer::scanQuotedLiteral); + + assertThat(problems).as("An unclosed literal must be reported at its opening quote.") + .containsExactly(problemInFirstLine(Messages.Parser_unclosedLiteral, 2)); + } + + @Test + void testExpectCharacter_whenTheExpectedCharacterIsNext_consumesIt() + { + final String remainder = remainderAfter("=x", lexer -> lexer.expectCharacter('=', "Unused.")); + + assertThat(remainder).as("The expected character must be consumed.").isEqualTo("x"); + } + + @Test + void testExpectCharacter_whenAnotherCharacterIsNext_recordsTheGivenMessage() + { + final List problems = + problemsAfterFailure("a:b", 1, lexer -> lexer.expectCharacter('=', "Expected equals.")); + + assertThat(problems).as("The problem must carry the message that the caller gave.") + .containsExactly(problemInFirstLine("Expected equals.", 1)); + } + + @Test + void testExpectCharacter_whenAtTheEnd_recordsTheGivenMessage() + { + final List problems = + problemsAfterFailure("a", 1, lexer -> lexer.expectCharacter('=', "Expected equals.")); + + assertThat(problems).as("The end of the text does not hold the expected character.") + .containsExactly(problemInFirstLine("Expected equals.", 1)); + } + + @Test + void testScanOpeningQuote_whenADoubleQuoteIsNext_returnsItAndMovesPastIt() + { + final XmlLexer lexer = lexerFor("\"x\""); + + final char quote = lexer.scanOpeningQuote("Unused."); + + assertThat(quote).as("The double quote must be returned.").isEqualTo('"'); + assertThat(lexer.position()).as("The opening quote must be consumed.").isEqualTo(1); + } + + @Test + void testScanOpeningQuote_whenASingleQuoteIsNext_returnsIt() + { + final XmlLexer lexer = lexerFor("'x'"); + + final char quote = lexer.scanOpeningQuote("Unused."); + + assertThat(quote).as("The single quote must be returned.").isEqualTo('\''); + } + + @Test + void testScanOpeningQuote_whenNoQuoteIsNext_recordsTheGivenMessage() + { + final List problems = + problemsAfterFailure("a=x", 2, lexer -> lexer.scanOpeningQuote("Expected quote.")); + + assertThat(problems).as("The problem must carry the message that the caller gave.") + .containsExactly(problemInFirstLine("Expected quote.", 2)); + } + + @Test + void testScanOpeningQuote_whenAtTheEnd_recordsTheGivenMessage() + { + final List problems = + problemsAfterFailure("a=", 2, lexer -> lexer.scanOpeningQuote("Expected quote.")); + + assertThat(problems).as("The end of the text does not hold a quote.") + .containsExactly(problemInFirstLine("Expected quote.", 2)); + } + + @Test + void testScanToClosingQuote_whenClosed_returnsTheOffsetOfTheClosingQuote() + { + final XmlLexer lexer = lexerFor("\"abc\" tail"); + lexer.advance(1); + + final int closing = lexer.scanToClosingQuote('"', 1); + + assertThat(closing).as("The offset of the closing quote must be returned.").isEqualTo(4); + assertThat(lexer.position()).as("The lexer must stand on the closing quote.").isEqualTo(4); + } + + @Test + void testScanToClosingQuote_whenTheValueHoldsTheOtherQuote_doesNotStopAtIt() + { + final XmlLexer lexer = lexerFor("'a\"b'"); + lexer.advance(1); + + final int closing = lexer.scanToClosingQuote('\'', 1); + + assertThat(closing).as("Only the matching quote closes the value.").isEqualTo(4); + } + + @Test + void testScanToClosingQuote_whenTheValueHoldsALessThan_recordsABlockingProblemAtIt() + { + final List problems = + problemsAfterFailure("\"a lexer.scanToClosingQuote('"', 1)); + + assertThat(problems).as("A less-than sign in a value must be reported where it is.") + .containsExactly(problemInFirstLine(Messages.Parser_lessThanInValue, 2)); + } + + @Test + void testScanToClosingQuote_whenUnclosed_recordsABlockingProblemAtTheOpeningQuote() + { + final List problems = + problemsAfterFailure("\"abc", 1, lexer -> lexer.scanToClosingQuote('"', 1)); + + assertThat(problems).as("An unclosed value must be reported at its opening quote.") + .containsExactly(problemInFirstLine(Messages.Parser_unclosedValue, 0)); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/XmlNamesTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/XmlNamesTest.java new file mode 100644 index 00000000..ca7234ca --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/XmlNamesTest.java @@ -0,0 +1,212 @@ +/* + * + * 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.eclipse.dataset.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.StringReader; +import java.util.ArrayList; +import java.util.List; + +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.xml.sax.InputSource; +import org.xml.sax.helpers.DefaultHandler; + +/** + * Tests the XML 1.0 Name production that dbUnit flat XML table and column names must follow, and compares it + * with the XML parser of the Java runtime, which dbUnit loads a dataset with: for every code point of the + * basic plane, and for a sample beyond it, the parser accepts the code point in a name exactly when + * {@code XmlNames} does, at the start of a name and inside it. + */ +class XmlNamesTest +{ + private static final int LAST_BASIC_PLANE_CODE_POINT = 0xFFFF; + + @ParameterizedTest + @ValueSource(strings = { "USERS", "order_items", "a.b-c", "ns:TABLE", "Ä1" }) + void testIsValidName_whenNameFollowsTheXmlNameProduction_returnsTrue(final String name) + { + assertThat(XmlNames.isValidName(name)).as("'" + name + "' must be a valid XML 1.0 name.").isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = { "", "1ABC", "-a", "a b", "a', 0xD7, 0xF7, 0x2000, 0x2190, 0x3001, 0x9FA6, 0xD800, 0xF900, + 0xFFFE, 0x10000, 0x20000 }) + void testIsNameChar_whenCodePointIsNoPartOfANameOfTheFourthEdition_returnsFalse(final int codePoint) + { + assertThat(XmlNames.isNameChar(codePoint)).as("U+%04X must not be a NameChar.", codePoint).isFalse(); + } + + @Test + void testIsNameStartChar_forEveryBasicPlaneCodePoint_agreesWithTheXmlParserOfTheJavaRuntime() + throws Exception + { + final SAXParser parser = SAXParserFactory.newInstance().newSAXParser(); + final List disagreements = new ArrayList<>(); + + for (int codePoint = 0; codePoint <= LAST_BASIC_PLANE_CODE_POINT; codePoint++) + { + final boolean parserAccepts = parses(parser, "<" + asText(codePoint) + "b/>"); + if (parserAccepts != XmlNames.isNameStartChar(codePoint)) + { + disagreements.add(String.format("U+%04X: parser %s", codePoint, parserAccepts)); + } + } + + assertThat(disagreements).as("Every code point that starts a name for the parser must start one for " + + "XmlNames, and no other.").isEmpty(); + } + + @Test + void testIsNameChar_forEveryBasicPlaneCodePoint_agreesWithTheXmlParserOfTheJavaRuntime() throws Exception + { + final SAXParser parser = SAXParserFactory.newInstance().newSAXParser(); + final List disagreements = new ArrayList<>(); + + for (int codePoint = 0; codePoint <= LAST_BASIC_PLANE_CODE_POINT; codePoint++) + { + final boolean parserAccepts = parses(parser, ""); + if (parserAccepts != XmlNames.isNameChar(codePoint)) + { + disagreements.add(String.format("U+%04X: parser %s", codePoint, parserAccepts)); + } + } + + assertThat(disagreements).as("Every code point that may be part of a name for the parser must be one " + + "for XmlNames, and no other.").isEmpty(); + } + + @Test + void testIsNameStartCharAndIsNameChar_forCodePointsBeyondTheBasicPlane_agreeWithTheParser() + throws Exception + { + final SAXParser parser = SAXParserFactory.newInstance().newSAXParser(); + final List disagreements = new ArrayList<>(); + + for (int codePoint = 0x10000; codePoint <= Character.MAX_CODE_POINT; codePoint += 0x101) + { + final boolean startAccepted = parses(parser, "<" + asText(codePoint) + "b/>"); + final boolean insideAccepted = parses(parser, ""); + if (startAccepted != XmlNames.isNameStartChar(codePoint) + || insideAccepted != XmlNames.isNameChar(codePoint)) + { + disagreements.add(String.format("U+%05X", codePoint)); + } + } + + assertThat(disagreements).as("The parser takes no character beyond U+FFFF in a name.").isEmpty(); + } + + private static String asText(final int codePoint) + { + return new String(Character.toChars(codePoint)); + } + + private static boolean parses(final SAXParser parser, final String xml) + { + parser.reset(); + try + { + parser.parse(new InputSource(new StringReader(xml)), new DefaultHandler()); + return true; + } + catch (final Exception e) + { + return false; + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/model/DatasetColumnTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/model/DatasetColumnTest.java new file mode 100644 index 00000000..77531d19 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/model/DatasetColumnTest.java @@ -0,0 +1,126 @@ +/* + * + * 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.eclipse.dataset.core.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Locale; + +import org.junit.jupiter.api.Test; + +/** + * Tests the key of a {@link DatasetColumn}, and which value dbUnit loads for a cell of it. + */ +class DatasetColumnTest +{ + @Test + void testKeyOf_forAName_returnsItInUpperCase() + { + assertThat(DatasetColumn.keyOf("Name")).as("A column is keyed by its upper-cased name.") + .isEqualTo("NAME"); + } + + @Test + void testKeyOf_forNamesThatDifferOnlyInCase_returnsOneKey() + { + assertThat(DatasetColumn.keyOf("name")).as("Two spellings of a name name one column.") + .isEqualTo(DatasetColumn.keyOf("NAME")); + } + + @Test + void testKeyOf_whenTheDefaultLocaleUpperCasesIDifferently_doesNotFollowIt() + { + final Locale defaultLocale = Locale.getDefault(); + Locale.setDefault(Locale.forLanguageTag("tr")); + try + { + assertThat(DatasetColumn.keyOf("title")) + .as("The key must not depend on the language of the user, which Turkish upper-cases i " + + "to a dotted capital I.") + .isEqualTo("TITLE"); + } + finally + { + Locale.setDefault(defaultLocale); + } + } + + @Test + void testKey_whenTheNameHasLowerCaseLetters_returnsTheKeyOfTheName() + { + final DatasetColumn column = new DatasetColumn("Name", false, true, false); + + assertThat(column.key()).as("The key of a column is the key of its name.").isEqualTo("NAME"); + } + + @Test + void testEffectiveValue_whenTheCellHasAValue_returnsTheValue() + { + final DatasetColumn column = new DatasetColumn("STATUS", true, true, false, "ACTIVE"); + + assertThat(column.effectiveValue("x")).as("A value that is present must win over the default.") + .isEqualTo("x"); + } + + @Test + void testEffectiveValue_whenTheCellHasTheEmptyString_returnsTheEmptyString() + { + final DatasetColumn column = new DatasetColumn("STATUS", true, true, false, "ACTIVE"); + + assertThat(column.effectiveValue("")).as("An empty string is a value, not an absent attribute.") + .isEmpty(); + } + + @Test + void testEffectiveValue_whenTheCellHasNoValue_returnsTheDefault() + { + final DatasetColumn column = new DatasetColumn("STATUS", true, false, false, "ACTIVE"); + + assertThat(column.effectiveValue(null)).as("An absent attribute loads the default.") + .isEqualTo("ACTIVE"); + } + + @Test + void testEffectiveValue_whenTheColumnHasNoDefault_returnsNullForAnAbsentValue() + { + final DatasetColumn column = new DatasetColumn("STATUS", true, false, false); + + assertThat(column.effectiveValue(null)).as("Without a default, an absent attribute is NULL.") + .isNull(); + } + + @Test + void testHasDefaultValue_whenTheDefaultIsTheEmptyString_returnsTrue() + { + final DatasetColumn column = new DatasetColumn("STATUS", true, false, false, ""); + + assertThat(column.hasDefaultValue()).as("An empty default still loads a value, not NULL.").isTrue(); + } + + @Test + void testHasDefaultValue_whenTheColumnIsCreatedWithoutADefault_returnsFalse() + { + final DatasetColumn column = new DatasetColumn("STATUS", true, false, false); + + assertThat(column.hasDefaultValue()).as("The four-argument constructor must leave the column " + + "without a default.").isFalse(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/model/DatasetModelTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/model/DatasetModelTest.java new file mode 100644 index 00000000..e47ec9e3 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/model/DatasetModelTest.java @@ -0,0 +1,202 @@ +/* + * + * 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.eclipse.dataset.core.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Test; + +/** + * Tests table lookup, per-table problem filtering, and the EMPTY constant of a dataset model. + */ +class DatasetModelTest +{ + private static final DatasetTable USERS_TABLE = + new DatasetTable("USERS", "USERS", List.of(), List.of(), false); + + private static final DatasetProblem USERS_PROBLEM = new DatasetProblem( + ProblemCode.TABLE_NAME_CASE_VARIANTS, ProblemSeverity.WARNING, + "Table USERS is also spelled Users.", "USERS", null, -1, 10, 5); + + private static final DatasetProblem DOCUMENT_PROBLEM = new DatasetProblem(ProblemCode.NOT_WELL_FORMED, + ProblemSeverity.ERROR, "The document is not well-formed.", null, null, -1, 0, 1); + + private static DatasetProblem columnProblem(final String tableKey, final String columnName, + final ProblemSeverity severity) + { + return new DatasetProblem(ProblemCode.COLUMN_NOT_IN_FIRST_ROW, severity, "A column problem.", + tableKey, columnName, -1, 0, 1); + } + + @Test + void testIsBlank_whenBuiltAsBlank_isTrue() + { + final DatasetModel model = new DatasetModel(List.of(), List.of(), false, true); + + assertThat(model.isBlank()).as("A model that is built as blank must say so.").isTrue(); + } + + @Test + void testIsBlank_whenBuiltWithoutSayingSo_isFalse() + { + final DatasetModel model = new DatasetModel(List.of(USERS_TABLE), List.of(), true); + + assertThat(model.isBlank()).as("A model of a document with content is not blank.").isFalse(); + } + + @Test + void testIsBlank_forTheModelBeforeTheFirstParse_isFalse() + { + assertThat(DatasetModel.EMPTY.isBlank()) + .as("Nothing is known of the text before the first parse, so the model is not blank.") + .isFalse(); + } + + @Test + void testFindTable_whenKeyExists_returnsTheTable() + { + final DatasetModel model = new DatasetModel(List.of(USERS_TABLE), List.of(), true); + + final Optional found = model.findTable("USERS"); + + assertThat(found).as("findTable must return the table with a matching key.").contains(USERS_TABLE); + } + + @Test + void testFindTable_whenKeyIsAbsent_returnsEmpty() + { + final DatasetModel model = new DatasetModel(List.of(USERS_TABLE), List.of(), true); + + final Optional found = model.findTable("ORDERS"); + + assertThat(found).as("findTable must return empty when no table matches.").isEmpty(); + } + + @Test + void testGetProblemsWithTableKey_whenCalled_returnsOnlyThatTablesProblems() + { + final DatasetModel model = new DatasetModel(List.of(USERS_TABLE), + List.of(USERS_PROBLEM, DOCUMENT_PROBLEM), true); + + final List problems = model.getProblems("USERS"); + + assertThat(problems).as("getProblems(tableKey) must filter by the table key.") + .containsExactly(USERS_PROBLEM); + } + + @Test + void testGetProblemsWithTableKey_forTheDocumentLevelProblems_usesANullKey() + { + final DatasetModel model = new DatasetModel(List.of(USERS_TABLE), + List.of(USERS_PROBLEM, DOCUMENT_PROBLEM), true); + + assertThat(model.getProblems(null)).as("The problems of no table are the document's.") + .containsExactly(DOCUMENT_PROBLEM); + } + + @Test + void testGetProblemsWithTableKey_forATableWithoutProblems_isEmpty() + { + final DatasetModel model = new DatasetModel(List.of(USERS_TABLE), List.of(USERS_PROBLEM), true); + + assertThat(model.getProblems("ORDERS")).as("A table without problems has an empty list.").isEmpty(); + } + + @Test + void testGetProblemsWithTableKey_whenAskedAgain_returnsTheListItBuiltBefore() + { + final DatasetModel model = new DatasetModel(List.of(USERS_TABLE), + List.of(USERS_PROBLEM, DOCUMENT_PROBLEM), true); + + assertThat(model.getProblems("USERS")) + .as("The problems of a table are filtered once, because the grid asks on every paint.") + .isSameAs(model.getProblems("USERS")); + } + + @Test + void testGetProblemsWithTableKey_whenSeveralProblemsBelongToTheTable_keepsTheirOrder() + { + final DatasetProblem second = columnProblem("USERS", "NAME", ProblemSeverity.INFO); + final DatasetModel model = new DatasetModel(List.of(USERS_TABLE), + List.of(USERS_PROBLEM, DOCUMENT_PROBLEM, second), true); + + assertThat(model.getProblems("USERS")).as("The problems must stay in the order they were found in.") + .containsExactly(USERS_PROBLEM, second); + } + + @Test + void testGetWorstSeverity_whenAColumnHasProblemsOfSeveralSeverities_returnsTheWorst() + { + final DatasetModel model = new DatasetModel(List.of(USERS_TABLE), + List.of(columnProblem("USERS", "NAME", ProblemSeverity.INFO), + columnProblem("USERS", "NAME", ProblemSeverity.ERROR), + columnProblem("USERS", "NAME", ProblemSeverity.WARNING)), + true); + + assertThat(model.getWorstSeverity("USERS", "NAME")) + .as("An error is worse than a warning, which is worse than a note, in any order.") + .contains(ProblemSeverity.ERROR); + } + + @Test + void testGetWorstSeverity_whenTheWorstIsAWarning_returnsTheWarning() + { + final DatasetModel model = new DatasetModel(List.of(USERS_TABLE), + List.of(columnProblem("USERS", "NAME", ProblemSeverity.WARNING), + columnProblem("USERS", "NAME", ProblemSeverity.INFO)), + true); + + assertThat(model.getWorstSeverity("USERS", "NAME")) + .as("A warning is worse than a note, whichever comes first.") + .contains(ProblemSeverity.WARNING); + } + + @Test + void testGetWorstSeverity_forAnotherColumnOrTableOrOneWithoutAColumn_isEmpty() + { + final DatasetModel model = new DatasetModel(List.of(USERS_TABLE), + List.of(columnProblem("USERS", "NAME", ProblemSeverity.ERROR), USERS_PROBLEM, + columnProblem("ORDERS", "ID", ProblemSeverity.ERROR)), + true); + + assertThat(List.of(model.getWorstSeverity("USERS", "ID"), model.getWorstSeverity("ORDERS", "NAME"), + model.getWorstSeverity("USERS", null), model.getWorstSeverity("PRODUCTS", "ID"))) + .as("Only the problems of that column of that table count, and a problem of a table as a " + + "whole belongs to no column.") + .containsOnly(Optional.empty()); + } + + @Test + void testIsEditable_whenEmpty_isFalse() + { + assertThat(DatasetModel.EMPTY.isEditable()).as("EMPTY must not be editable.").isFalse(); + } + + @Test + void testGetTablesAndGetProblems_whenEmpty_areEmpty() + { + assertThat(DatasetModel.EMPTY.getTables()).as("EMPTY must have no tables.").isEmpty(); + assertThat(DatasetModel.EMPTY.getProblems()).as("EMPTY must have no problems.").isEmpty(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/model/DatasetRowTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/model/DatasetRowTest.java new file mode 100644 index 00000000..5629f519 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/model/DatasetRowTest.java @@ -0,0 +1,78 @@ +/* + * + * 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.eclipse.dataset.core.model; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * Tests that a dataset row preserves null values and never exposes or shares its internal list. + */ +class DatasetRowTest +{ + @Test + void testGetValues_whenAValueIsNull_preservesTheNullValue() + { + final DatasetRow row = new DatasetRow(Arrays.asList("a", null, "b")); + + assertThat(row.getValues()).as("A null value means NULL and must be preserved.") + .containsExactly("a", null, "b"); + } + + @Test + void testConstructor_whenTheSourceListIsModifiedAfterward_leavesTheRowUnaffected() + { + final List source = new ArrayList<>(Arrays.asList("a", "b")); + final DatasetRow row = new DatasetRow(source); + + source.set(0, "changed"); + + assertThat(row.getValues()).as("The constructor must copy the values defensively.") + .containsExactly("a", "b"); + } + + @Test + void testGetValues_whenCalled_returnsAnUnmodifiableList() + { + final DatasetRow row = new DatasetRow(Arrays.asList("a", "b")); + + final List values = row.getValues(); + + assertThatThrownBy(() -> values.add("c")).as("The returned list must be unmodifiable.") + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void testGetValueAndSize_whenConstructed_matchTheGivenValues() + { + final DatasetRow row = new DatasetRow(Arrays.asList("a", null, "c")); + + assertThat(row.size()).as("The size must equal the number of values given.").isEqualTo(3); + assertThat(row.getValue(1)).as("A null value must be returned as null.").isNull(); + assertThat(row.getValue(2)).as("A non-null value must be returned unchanged.").isEqualTo("c"); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/model/DatasetTableTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/model/DatasetTableTest.java new file mode 100644 index 00000000..8bbc89ac --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/model/DatasetTableTest.java @@ -0,0 +1,203 @@ +/* + * + * 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.eclipse.dataset.core.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +import org.junit.jupiter.api.Test; + +/** + * Tests case-insensitive column lookup, the plain getters, and the default values of a dataset table. + */ +class DatasetTableTest +{ + private static final DatasetColumn ID_COLUMN = new DatasetColumn("ID", true, true, false); + + private static final DatasetColumn NAME_COLUMN = new DatasetColumn("NAME", true, true, false); + + @Test + void testKeyOf_whenTableNamesAreNotCaseSensitive_returnsTheNameInUpperCase() + { + assertThat(DatasetTable.keyOf("Users", false)).as("A table is keyed by its upper-cased name.") + .isEqualTo("USERS"); + } + + @Test + void testKeyOf_whenTableNamesAreCaseSensitive_returnsTheNameAsItIs() + { + assertThat(DatasetTable.keyOf("Users", true)).as("The exact name is the key of a table whose name " + + "is case-sensitive.").isEqualTo("Users"); + } + + @Test + void testKeyOf_whenTheDefaultLocaleUpperCasesIDifferently_doesNotFollowIt() + { + final Locale defaultLocale = Locale.getDefault(); + Locale.setDefault(Locale.forLanguageTag("tr")); + try + { + assertThat(DatasetTable.keyOf("titles", false)) + .as("The key must not depend on the language of the user.").isEqualTo("TITLES"); + } + finally + { + Locale.setDefault(defaultLocale); + } + } + + @Test + void testGetColumnIndex_whenNameMatchesIgnoringCase_returnsTheIndex() + { + final DatasetTable table = new DatasetTable("USERS", "USERS", List.of(ID_COLUMN, NAME_COLUMN), + List.of(), false); + + assertThat(table.getColumnIndex("name")).as("Column lookup must be case-insensitive.") + .isEqualTo(1); + } + + @Test + void testGetColumnIndex_whenNameIsAbsent_returnsMinusOne() + { + final DatasetTable table = new DatasetTable("USERS", "USERS", List.of(ID_COLUMN, NAME_COLUMN), + List.of(), false); + + assertThat(table.getColumnIndex("EMAIL")).as("A column that does not exist must return -1.") + .isEqualTo(-1); + } + + @Test + void testGetColumnIndex_whenAnotherColumnIsEqualIgnoringCaseYetDistinct_returnsTheColumnWithTheSameKey() + { + final DatasetColumn dottedColumn = new DatasetColumn("\u0130D", true, true, false); + final DatasetTable table = new DatasetTable("USERS", "USERS", List.of(dottedColumn, ID_COLUMN), + List.of(), false); + + assertThat(table.getColumnIndex("ID")) + .as("The ID column must be found, not the earlier column whose name equals it only " + + "ignoring case.") + .isEqualTo(1); + assertThat(table.getColumnIndex("\u0130D")).as("The column with the dotted capital I must be found.") + .isEqualTo(0); + } + + @Test + void testGetColumnIndex_whenTheNameSharesAKeyYetIsNotEqualIgnoringCase_returnsTheIndex() + { + final DatasetColumn column = new DatasetColumn("straße", true, true, false); + final DatasetTable table = new DatasetTable("ADDRESSES", "ADDRESSES", List.of(ID_COLUMN, column), + List.of(), false); + + assertThat(table.getColumnIndex("STRASSE")) + .as("A name that upper-cases to the column's key must find it, although the two names are " + + "not equal ignoring case.") + .isEqualTo(1); + } + + @Test + void testGetters_whenConstructed_returnTheGivenValues() + { + final DatasetRow row = new DatasetRow(List.of("1", "Alice")); + final DatasetTable table = new DatasetTable("USERS", "Users", List.of(ID_COLUMN, NAME_COLUMN), + List.of(row), true); + + assertThat(table.getKey()).as("getKey must return the given key.").isEqualTo("USERS"); + assertThat(table.getName()).as("getName must return the given display name.").isEqualTo("Users"); + assertThat(table.getColumns()).as("getColumns must return the given columns.") + .containsExactly(ID_COLUMN, NAME_COLUMN); + assertThat(table.getRows()).as("getRows must return the given rows.").containsExactly(row); + assertThat(table.isDeclaredOnly()).as("isDeclaredOnly must return the given flag.").isTrue(); + } + + @Test + void testIsDeclaredInExternalDtd_whenGivenNoFlag_isFalse() + { + final DatasetTable table = new DatasetTable("USERS", "USERS", List.of(ID_COLUMN), List.of(), false); + + assertThat(table.isDeclaredInExternalDtd()) + .as("A table created without the flag must not claim an external DTD declares it.") + .isFalse(); + } + + @Test + void testIsDeclaredInExternalDtd_whenGivenTheFlag_returnsIt() + { + final DatasetTable table = + new DatasetTable("USERS", "USERS", List.of(ID_COLUMN), List.of(), false, true); + + assertThat(table.isDeclaredInExternalDtd()).as("isDeclaredInExternalDtd must return the given flag.") + .isTrue(); + } + + @Test + void testGetEffectiveValue_whenTheCellHasAValue_returnsTheValueNotTheDefault() + { + final DatasetTable table = tableWithDefaultStatus(List.of("1", "x")); + + assertThat(table.getEffectiveValue(0, 1)) + .as("An attribute that is present must win over the default.").isEqualTo("x"); + } + + @Test + void testGetEffectiveValue_whenTheCellHasNoValueAndTheColumnHasADefault_returnsTheDefault() + { + final DatasetTable table = tableWithDefaultStatus(Arrays.asList("1", null)); + + assertThat(table.getEffectiveValue(0, 1)).as("An absent attribute loads the column's default.") + .isEqualTo("ACTIVE"); + } + + @Test + void testGetEffectiveValue_whenTheCellHasNoValueAndTheColumnHasNoDefault_returnsNull() + { + final DatasetTable table = tableWithDefaultStatus(Arrays.asList(null, "x")); + + assertThat(table.getEffectiveValue(0, 0)) + .as("An absent attribute of a column without a default is NULL.").isNull(); + } + + @Test + void testHasDefaultValues_whenSomeColumnHasADefault_returnsTrue() + { + assertThat(tableWithDefaultStatus(List.of("1", "x")).hasDefaultValues()) + .as("One column with a default is enough.").isTrue(); + } + + @Test + void testHasDefaultValues_whenNoColumnHasADefault_returnsFalse() + { + final DatasetTable table = new DatasetTable("USERS", "USERS", List.of(ID_COLUMN, NAME_COLUMN), + List.of(), false); + + assertThat(table.hasDefaultValues()).as("Without any default, an empty element is not a row.") + .isFalse(); + } + + private static DatasetTable tableWithDefaultStatus(final List rowValues) + { + final DatasetColumn statusColumn = new DatasetColumn("STATUS", true, true, false, "ACTIVE"); + return new DatasetTable("USERS", "USERS", List.of(ID_COLUMN, statusColumn), + List.of(new DatasetRow(rowValues)), false); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/model/ProblemCodeTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/model/ProblemCodeTest.java new file mode 100644 index 00000000..595db126 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/model/ProblemCodeTest.java @@ -0,0 +1,53 @@ +/* + * + * 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.eclipse.dataset.core.model; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.EnumSet; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +/** + * Guards which problem codes block editing, since the editor and the UI both depend on this set staying + * exactly these four. + */ +class ProblemCodeTest +{ + @Test + void testBlocksEditing_whenCheckedForEveryCode_isTrueForExactlyTheFourBlockingCodes() + { + final Set blocking = EnumSet.noneOf(ProblemCode.class); + for (final ProblemCode code : ProblemCode.values()) + { + if (code.blocksEditing()) + { + blocking.add(code); + } + } + + assertThat(blocking).as("Exactly NOT_WELL_FORMED, ROOT_NOT_DATASET, NESTED_ELEMENT, and " + + "UNSUPPORTED_ENTITY must block editing.") + .containsExactlyInAnyOrder(ProblemCode.NOT_WELL_FORMED, ProblemCode.ROOT_NOT_DATASET, + ProblemCode.NESTED_ELEMENT, ProblemCode.UNSUPPORTED_ENTITY); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/tsv/TabSeparatedValuesTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/tsv/TabSeparatedValuesTest.java new file mode 100644 index 00000000..9f3fcafe --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/tsv/TabSeparatedValuesTest.java @@ -0,0 +1,186 @@ +/* + * + * 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.eclipse.dataset.core.tsv; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link TabSeparatedValues} against the Clipboard specification's format and parse rules. + */ +class TabSeparatedValuesTest +{ + @Test + void testFormat_withASimpleGrid_joinsFieldsWithTabsAndRowsWithTheLineSeparator() + { + final List> rows = + List.of(Arrays.asList("1", "Alice"), Arrays.asList("2", "Bob")); + + final String text = TabSeparatedValues.format(rows, "\n"); + + assertThat(text).as("Fields must be tab-separated and every row must end with the separator.") + .isEqualTo("1\tAlice\n2\tBob\n"); + } + + @Test + void testFormat_whenAValueIsNull_writesAnUnquotedEmptyField() + { + final List> rows = List.of(Arrays.asList("1", null)); + + final String text = TabSeparatedValues.format(rows, "\n"); + + assertThat(text).as("A null value must format as an unquoted empty field.").isEqualTo("1\t\n"); + } + + @Test + void testFormat_whenAValueIsTheEmptyString_writesAQuotedEmptyField() + { + final List> rows = List.of(Arrays.asList("1", "")); + + final String text = TabSeparatedValues.format(rows, "\n"); + + assertThat(text).as("An empty string must format as a quoted empty field, distinct from NULL.") + .isEqualTo("1\t\"\"\n"); + } + + @Test + void testFormat_whenAValueContainsATabOrLineBreakOrQuote_quotesItAndDoublesEmbeddedQuotes() + { + final List> rows = + List.of(Arrays.asList("a\tb", "c\nd", "e\rf", "say \"hi\"", "\"leading")); + + final String text = TabSeparatedValues.format(rows, "\n"); + + assertThat(text).as("Special characters must be quoted, with embedded quotes doubled.") + .isEqualTo("\"a\tb\"\t\"c\nd\"\t\"e\rf\"\t\"say \"\"hi\"\"\"\t\"\"\"leading\"\n"); + } + + @Test + void testFormat_whenAValueHasNoSpecialCharacters_leavesItUnquoted() + { + final List> rows = List.of(List.of("plain")); + + final String text = TabSeparatedValues.format(rows, "\n"); + + assertThat(text).as("A value with no special characters must not be quoted.") + .isEqualTo("plain\n"); + } + + @Test + void testParse_ofEmptyText_returnsAnEmptyList() + { + assertThat(TabSeparatedValues.parse("")).as("Empty text must give an empty list.").isEmpty(); + } + + @Test + void testParse_withATrailingLineEnd_doesNotCreateAnExtraEmptyRow() + { + final List> rows = TabSeparatedValues.parse("1\tAlice\n2\tBob\n"); + + assertThat(rows).as("A trailing line end must not create an extra empty row.") + .containsExactly(List.of("1", "Alice"), List.of("2", "Bob")); + } + + @Test + void testParse_withNoTrailingLineEnd_stillReadsTheLastRow() + { + final List> rows = TabSeparatedValues.parse("1\tAlice\n2\tBob"); + + assertThat(rows).as("The last row must be read even with no trailing line end.") + .containsExactly(List.of("1", "Alice"), List.of("2", "Bob")); + } + + @Test + void testParse_whenRowsEndWithCrLfLfOrCr_acceptsAllThreeLineEnds() + { + final List> rows = TabSeparatedValues.parse("a\r\nb\nc\rd"); + + assertThat(rows).as("CR LF, LF, and CR must each end a row.").containsExactly(List.of("a"), + List.of("b"), List.of("c"), List.of("d")); + } + + @Test + void testParse_withRaggedRows_padsShorterRowsWithNullToTheWidestRow() + { + final List> rows = TabSeparatedValues.parse("1\t2\t3\na\tb\n"); + + assertThat(rows).as("Shorter rows must be padded with NULL to the widest row's width.") + .containsExactly(List.of("1", "2", "3"), Arrays.asList("a", "b", null)); + } + + @Test + void testParse_withAnEmptyLine_readsItAsOneNullField() + { + final List> rows = TabSeparatedValues.parse("a\n\nb\n"); + + assertThat(rows).as("A blank line between rows must parse as one row with one NULL field.") + .containsExactly(List.of("a"), Arrays.asList((String) null), List.of("b")); + } + + @Test + void testParse_ofAQuotedEmptyField_readsItAsTheEmptyString() + { + final List> rows = TabSeparatedValues.parse("\"\"\n"); + + assertThat(rows).as("A quoted empty field must parse as the empty string, not NULL.") + .containsExactly(List.of("")); + } + + @Test + void testParse_withAQuotedFieldContainingTabsAndLineBreaksAndDoubledQuotes_unescapesIt() + { + final List> rows = + TabSeparatedValues.parse("\"a\tb\nc\"\t\"say \"\"hi\"\"\"\n"); + + assertThat(rows).as("A quoted field must keep its embedded tabs and line breaks, and unescape " + + "doubled quotes.").containsExactly(List.of("a\tb\nc", "say \"hi\"")); + } + + @Test + void testFormatThenParse_ofAnExcelStyleSample_roundTrips() + { + final List> original = List.of(Arrays.asList("1", "Alice \"the boss\"", "multi\nline"), + Arrays.asList("2", null, "plain")); + + final List> roundTripped = + TabSeparatedValues.parse(TabSeparatedValues.format(original, "\r\n")); + + assertThat(roundTripped).as("Formatting then parsing must reproduce the original values, with " + + "null read back as null.").containsExactly( + List.of("1", "Alice \"the boss\"", "multi\nline"), Arrays.asList("2", null, "plain")); + } + + @Test + void testFormatThenParse_ofNullAndTheEmptyString_keepsThemDistinct() + { + final List> original = List.of(Arrays.asList((String) null, "")); + + final List> roundTripped = + TabSeparatedValues.parse(TabSeparatedValues.format(original, "\n")); + + assertThat(roundTripped).as("NULL and the empty string must round-trip as distinct values.") + .containsExactly(Arrays.asList((String) null, "")); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/column-name-case-dtd.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/column-name-case-dtd.xml new file mode 100644 index 00000000..a1e01ab8 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/column-name-case-dtd.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/column-name-case.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/column-name-case.xml new file mode 100644 index 00000000..addf235c --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/column-name-case.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/column-sensing.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/column-sensing.xml new file mode 100644 index 00000000..8ed4d35b --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/column-sensing.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/content-model-both.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/content-model-both.xml new file mode 100644 index 00000000..02836ee8 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/content-model-both.xml @@ -0,0 +1,7 @@ + + +]> + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/doctype-root-name.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/doctype-root-name.xml new file mode 100644 index 00000000..bf95bb95 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/doctype-root-name.xml @@ -0,0 +1,11 @@ + + + + + + +]> + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd-defaults-external.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd-defaults-external.xml new file mode 100644 index 00000000..2df422f7 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd-defaults-external.xml @@ -0,0 +1,12 @@ + + +]> + + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd-defaults-internal.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd-defaults-internal.xml new file mode 100644 index 00000000..74afe1bb --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd-defaults-internal.xml @@ -0,0 +1,32 @@ + + + + + + + + +]> + + + + + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd-parameter-entity.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd-parameter-entity.xml new file mode 100644 index 00000000..0d2c4a9c --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd-parameter-entity.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd/column-name-case.dtd b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd/column-name-case.dtd new file mode 100644 index 00000000..6208b8ee --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd/column-name-case.dtd @@ -0,0 +1,6 @@ + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd/content-model-external.dtd b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd/content-model-external.dtd new file mode 100644 index 00000000..e064139b --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd/content-model-external.dtd @@ -0,0 +1,5 @@ + + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd/dtd-defaults.dtd b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd/dtd-defaults.dtd new file mode 100644 index 00000000..9ebcc4a4 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd/dtd-defaults.dtd @@ -0,0 +1,8 @@ + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd/flatXmlDataSetDtdDifferentCaseTest.dtd b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd/flatXmlDataSetDtdDifferentCaseTest.dtd new file mode 100644 index 00000000..88c57cbe --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd/flatXmlDataSetDtdDifferentCaseTest.dtd @@ -0,0 +1,15 @@ + + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd/flatXmlTableTest.dtd b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd/flatXmlTableTest.dtd new file mode 100644 index 00000000..ac24a386 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd/flatXmlTableTest.dtd @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd/parameter-entity.dtd b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd/parameter-entity.dtd new file mode 100644 index 00000000..bb822035 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd/parameter-entity.dtd @@ -0,0 +1,6 @@ + + + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/editor-sample.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/editor-sample.xml new file mode 100644 index 00000000..eac27339 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/editor-sample.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/first-element-empty.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/first-element-empty.xml new file mode 100644 index 00000000..8913bff6 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/first-element-empty.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/flatXmlDataSetDtdDifferentCaseTest.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/flatXmlDataSetDtdDifferentCaseTest.xml new file mode 100644 index 00000000..5e20030c --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/flatXmlDataSetDtdDifferentCaseTest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/flatXmlDataSetDuplicateMultipleCaseTest.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/flatXmlDataSetDuplicateMultipleCaseTest.xml new file mode 100644 index 00000000..bad2053c --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/flatXmlDataSetDuplicateMultipleCaseTest.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/flatXmlDataSetDuplicateTest.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/flatXmlDataSetDuplicateTest.xml new file mode 100644 index 00000000..2597beac --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/flatXmlDataSetDuplicateTest.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/flatXmlDataSetTest.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/flatXmlDataSetTest.xml new file mode 100644 index 00000000..bfbd2e40 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/flatXmlDataSetTest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/flatXmlTableTest.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/flatXmlTableTest.xml new file mode 100644 index 00000000..de77ecd5 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/flatXmlTableTest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/internal-subset.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/internal-subset.xml new file mode 100644 index 00000000..6ada86d9 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/internal-subset.xml @@ -0,0 +1,17 @@ + + + + + + +]> + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/iso-8859-1.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/iso-8859-1.xml new file mode 100644 index 00000000..0e670bcc --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/iso-8859-1.xml @@ -0,0 +1,4 @@ + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/bare-ampersand-in-text.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/bare-ampersand-in-text.xml new file mode 100644 index 00000000..57bd07e7 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/bare-ampersand-in-text.xml @@ -0,0 +1,5 @@ + + + R&D fixtures + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/content-after-root.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/content-after-root.xml new file mode 100644 index 00000000..9836c69b --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/content-after-root.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/duplicate-attribute.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/duplicate-attribute.xml new file mode 100644 index 00000000..221f765c --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/duplicate-attribute.xml @@ -0,0 +1,4 @@ + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/less-than-in-value.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/less-than-in-value.xml new file mode 100644 index 00000000..770cad24 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/less-than-in-value.xml @@ -0,0 +1,4 @@ + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/mismatched-end-tag.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/mismatched-end-tag.xml new file mode 100644 index 00000000..83c01bd7 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/mismatched-end-tag.xml @@ -0,0 +1,4 @@ + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/missing-whitespace-between-attributes.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/missing-whitespace-between-attributes.xml new file mode 100644 index 00000000..d7b051d9 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/missing-whitespace-between-attributes.xml @@ -0,0 +1,4 @@ + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/nested-element.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/nested-element.xml new file mode 100644 index 00000000..0a27f237 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/nested-element.xml @@ -0,0 +1,4 @@ + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/root-not-dataset.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/root-not-dataset.xml new file mode 100644 index 00000000..bb920902 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/root-not-dataset.xml @@ -0,0 +1,3 @@ + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/undeclared-entity-in-text.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/undeclared-entity-in-text.xml new file mode 100644 index 00000000..ef2a0cca --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/undeclared-entity-in-text.xml @@ -0,0 +1,4 @@ + + +   + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/unknown-entity.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/unknown-entity.xml new file mode 100644 index 00000000..754d4c73 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/unknown-entity.xml @@ -0,0 +1,4 @@ + + + + diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/unterminated-comment.xml b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/unterminated-comment.xml new file mode 100644 index 00000000..4f29ba3e --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/unterminated-comment.xml @@ -0,0 +1,5 @@ + + + + ${project.build.directory} + + + com.github.spotbugs + spotbugs-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.maven.plugins + maven-pmd-plugin + diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/DatasetImages.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/DatasetImages.java new file mode 100644 index 00000000..7e239061 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/DatasetImages.java @@ -0,0 +1,135 @@ +/* + * + * 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.eclipse.dataset.ui; + +import org.eclipse.jface.resource.ImageDescriptor; +import org.eclipse.jface.resource.ImageRegistry; +import org.eclipse.swt.graphics.Image; +import org.eclipse.ui.plugin.AbstractUIPlugin; + +/** + * The images of the dataset editor UI, registered in {@link DatasetUiPlugin}'s image registry. + * + * @since 1.0.0 + */ +public final class DatasetImages +{ + /** + * The key for the dataset object icon. + */ + public static final String IMG_DATASET = "IMG_DATASET"; + + /** + * The key for the insert-row-above toolbar icon. + */ + public static final String IMG_INSERT_ROW_ABOVE = "IMG_INSERT_ROW_ABOVE"; + + /** + * The key for the insert-row-below toolbar icon. + */ + public static final String IMG_INSERT_ROW_BELOW = "IMG_INSERT_ROW_BELOW"; + + /** + * The key for the duplicate-rows toolbar icon. + */ + public static final String IMG_DUPLICATE_ROWS = "IMG_DUPLICATE_ROWS"; + + /** + * The key for the delete-rows toolbar icon. + */ + public static final String IMG_DELETE_ROWS = "IMG_DELETE_ROWS"; + + /** + * The key for the add-column toolbar icon. + */ + public static final String IMG_ADD_COLUMN = "IMG_ADD_COLUMN"; + + /** + * The key for the delete-column toolbar icon. + */ + public static final String IMG_DELETE_COLUMN = "IMG_DELETE_COLUMN"; + + /** + * The key for the add-table toolbar icon. + */ + public static final String IMG_ADD_TABLE = "IMG_ADD_TABLE"; + + /** + * The key for the set-null toolbar icon. + */ + public static final String IMG_SET_NULL = "IMG_SET_NULL"; + + /** + * The key for the fill-down toolbar icon. + */ + public static final String IMG_FILL_DOWN = "IMG_FILL_DOWN"; + + /** + * The key for the New Dataset wizard's banner image. + */ + public static final String IMG_NEW_DATASET_WIZBAN = "IMG_NEW_DATASET_WIZBAN"; + + private DatasetImages() + { + } + + static void initializeImageRegistry(final ImageRegistry registry) + { + register(registry, IMG_DATASET, "icons/obj16/dataset.png"); + register(registry, IMG_INSERT_ROW_ABOVE, "icons/etool16/insert_row_above.png"); + register(registry, IMG_INSERT_ROW_BELOW, "icons/etool16/insert_row_below.png"); + register(registry, IMG_DUPLICATE_ROWS, "icons/etool16/duplicate_rows.png"); + register(registry, IMG_DELETE_ROWS, "icons/etool16/delete_rows.png"); + register(registry, IMG_ADD_COLUMN, "icons/etool16/add_column.png"); + register(registry, IMG_DELETE_COLUMN, "icons/etool16/delete_column.png"); + register(registry, IMG_ADD_TABLE, "icons/etool16/add_table.png"); + register(registry, IMG_SET_NULL, "icons/etool16/set_null.png"); + register(registry, IMG_FILL_DOWN, "icons/etool16/fill_down.png"); + register(registry, IMG_NEW_DATASET_WIZBAN, "icons/wizban/new_dataset_wiz.png"); + } + + private static void register(final ImageRegistry registry, final String key, final String path) + { + registry.put(key, AbstractUIPlugin.imageDescriptorFromPlugin(DatasetUiPlugin.PLUGIN_ID, path)); + } + + /** + * Returns one of this class's images. + * + * @param key One of this class's {@code IMG_} constants. + * @return The image, or a placeholder when the key is missing or the image failed to load. + */ + public static Image getImage(final String key) + { + return DatasetUiPlugin.getDefault().getImageRegistry().get(key); + } + + /** + * Returns the descriptor of one of this class's images. + * + * @param key One of this class's {@code IMG_} constants. + * @return The image descriptor, or null when the key is missing. + */ + public static ImageDescriptor getImageDescriptor(final String key) + { + return DatasetUiPlugin.getDefault().getImageRegistry().getDescriptor(key); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/DatasetUiPlugin.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/DatasetUiPlugin.java index 2dd10f5b..0d77692a 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/DatasetUiPlugin.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/DatasetUiPlugin.java @@ -20,6 +20,7 @@ */ package org.dbunit.eclipse.dataset.ui; +import org.eclipse.jface.resource.ImageRegistry; import org.eclipse.ui.plugin.AbstractUIPlugin; import org.osgi.framework.BundleContext; @@ -72,4 +73,10 @@ public void stop(final BundleContext context) throws Exception plugin = null; super.stop(context); } + + @Override + protected void initializeImageRegistry(final ImageRegistry registry) + { + DatasetImages.initializeImageRegistry(registry); + } } diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/Messages.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/Messages.java new file mode 100644 index 00000000..340d20ba --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/Messages.java @@ -0,0 +1,449 @@ +/* + * + * 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.eclipse.dataset.ui; + +import org.eclipse.osgi.util.NLS; + +/** + * Externalized, user-visible strings of the dataset editor UI bundle. + * + * @since 1.0.0 + */ +// NLS binds each field to the message key of the same name, so the field names follow the keys. +@SuppressWarnings("PMD.FieldNamingConventions") +public final class Messages extends NLS +{ + private static final String BUNDLE_NAME = "org.dbunit.eclipse.dataset.ui.messages"; + + /** + * The title of the Tables page. + */ + public static String Editor_tablesPageText; + + /** + * The title of the Source page. + */ + public static String Editor_sourcePageText; + + /** + * The description at the top of the preference page. + */ + public static String PreferencePage_description; + + /** + * The label of the preference page's NULL display text field. + */ + public static String PreferencePage_nullDisplayText; + + /** + * The preference page's error message for a blank NULL display text. + */ + public static String PreferencePage_nullDisplayTextBlank; + + /** + * The label of the preference page's column sensing option. + */ + public static String PreferencePage_assumeColumnSensing; + + /** + * The label of the preference page's table name case option. + */ + public static String PreferencePage_caseSensitiveTableNames; + + /** + * The window title of the New dbUnit Flat XML Dataset wizard. + */ + public static String NewDatasetWizard_windowTitle; + + /** + * The title of the New dbUnit Flat XML Dataset wizard's page. + */ + public static String NewDatasetWizard_pageTitle; + + /** + * The description of the New dbUnit Flat XML Dataset wizard's page. + */ + public static String NewDatasetWizard_pageDescription; + + /** + * The title of the dialog shown when the new dataset file fails to open in an editor. + */ + public static String NewDatasetWizard_openErrorTitle; + + /** + * The label of the Insert Row Above command in menus. + */ + public static String Action_insertRowAbove; + + /** + * The label of the Insert Row Below command in menus and on the toolbar. + */ + public static String Action_insertRowBelow; + + /** + * The label of the Duplicate Rows command in menus. + */ + public static String Action_duplicateRows; + + /** + * The label of the Delete Rows command in menus and on the toolbar, and the title of its error dialog. + */ + public static String Action_deleteRows; + + /** + * The label of the Move Rows Up command in menus. + */ + public static String Action_moveRowsUp; + + /** + * The label of the Move Rows Down command in menus. + */ + public static String Action_moveRowsDown; + + /** + * The label of the Add Column command in menus and on the toolbar. + */ + public static String Action_addColumn; + + /** + * The label of the Rename Column command in menus. + */ + public static String Action_renameColumn; + + /** + * The label of the Delete Column command in menus and on the toolbar, and the title of its confirmation. + */ + public static String Action_deleteColumn; + + /** + * The label of the Add Table command in menus and on the toolbar. + */ + public static String Action_addTable; + + /** + * The label of the Rename Table command in menus. + */ + public static String Action_renameTable; + + /** + * The label of the Delete Table command in menus, and the title of its confirmation. + */ + public static String Action_deleteTable; + + /** + * The label of the Set to NULL command in menus. + */ + public static String Action_setNull; + + /** + * The label of the Set to Empty String command in menus. + */ + public static String Action_setEmptyString; + + /** + * The label of the Fill Down command in menus, and the title of its error dialog. + */ + public static String Action_fillDown; + + /** + * The label of the Edit Cell in Dialog command in menus. + */ + public static String Action_editCellInDialog; + + /** + * The label of the Show in Source command in menus. + */ + public static String Action_showInSource; + + /** + * The label of the Tables page's Cut action. + */ + public static String Action_cut; + + /** + * The label of the Tables page's Copy action. + */ + public static String Action_copy; + + /** + * The label of the Tables page's Paste action, and the title of its error dialog. + */ + public static String Action_paste; + + /** + * The label of the Tables page's Delete action. + */ + public static String Action_delete; + + /** + * The label of the Tables page's Select All action. + */ + public static String Action_selectAll; + + /** + * The title of the dialog that asks for a new column's name. + */ + public static String ColumnDialog_addTitle; + + /** + * The title of the dialog that asks for a column's new name. + */ + public static String ColumnDialog_renameTitle; + + /** + * The prompt for a column name. + */ + public static String ColumnDialog_name; + + /** + * The prompt for a new column's name in a table the DTD declares, with the reason to update the DTD. + */ + public static String ColumnDialog_nameWithDtdWarning; + + /** + * The prompt for a declared column's new name, with the reason to update the DTD too. + */ + public static String ColumnDialog_renameNameWithDtdWarning; + + /** + * The confirmation of deleting a column with other than one value; {0} is the column, {1} the number of + * values. + */ + public static String DeleteColumnDialog_message; + + /** + * The confirmation of deleting a column with one value; {0} is the column. + */ + public static String DeleteColumnDialog_messageOneValue; + + /** + * The note added to the confirmation of deleting a column that the DTD declares. + */ + public static String DeleteColumnDialog_dtdNote; + + /** + * The confirmation of deleting a table with other than one row; {0} is the table, {1} the number of rows. + */ + public static String DeleteTableDialog_message; + + /** + * The confirmation of deleting a table with one row; {0} is the table. + */ + public static String DeleteTableDialog_messageOneRow; + + /** + * The note added to the confirmation of deleting a table that the DTD declares. + */ + public static String DeleteTableDialog_dtdNote; + + /** + * The title of the dialog that asks for a new table's name and columns. + */ + public static String TableDialog_addTitle; + + /** + * The title of the dialog that asks for a table's new name. + */ + public static String TableDialog_renameTitle; + + /** + * The prompt for a table name. + */ + public static String TableDialog_name; + + /** + * The prompt for a declared-only table's new name, with the reason to change the DTD instead. + */ + public static String TableDialog_nameWithDtdWarning; + + /** + * The prompt for the new name of a table that a DTD file declares, with the reason to rename it in the + * DTD file too. + */ + public static String TableDialog_renameNameWithDtdWarning; + + /** + * The prompt for a new table's optional column names. + */ + public static String TableDialog_columnNames; + + /** + * The error for an empty table or column name. + */ + public static String NameValidator_empty; + + /** + * The error for a name that is not an XML name; {0} is the name. + */ + public static String NameValidator_invalid; + + /** + * The error for a name already in use; {0} is the name. + */ + public static String NameValidator_used; + + /** + * The error for a table name reserved for the root element; {0} is the name. + */ + public static String TableNameValidator_reserved; + + /** + * The status message for one pasted column beyond the table's last column; {0} is 1. + */ + public static String Paste_ignoredColumn; + + /** + * The status message for pasted columns beyond the table's last column; {0} is their number. + */ + public static String Paste_ignoredColumns; + + /** + * The status message for one pasted row to append that has no value; {0} is 1. + */ + public static String Paste_skippedRow; + + /** + * The status message for pasted rows to append that have no value; {0} is their number. + */ + public static String Paste_skippedRows; + + /** + * The error for a selection that Copy and Cut cannot put on the clipboard, because its rows have + * different selected columns. + */ + public static String Copy_selectionNotAligned; + + /** + * The Tables page banner's link to the problem's text on the Source page. + */ + public static String ErrorBanner_showInSource; + + /** + * The Tables page banner's message for a dataset that cannot be changed. + */ + public static String ErrorBanner_readOnly; + + /** + * The header of the Tables page's problems list; {0} is the number of problems. + */ + public static String ProblemsSection_header; + + /** + * The Tables page's message for an empty file. + */ + public static String TablesPage_emptyFile; + + /** + * The label of the Tables page's button that writes an empty dataset into an empty file. + */ + public static String TablesPage_createEmptyDataset; + + /** + * The Tables page's message for a dataset without tables. + */ + public static String TablesPage_noTables; + + /** + * The tooltip of a table's sheet tab; {0} is the row count text, {1} the column count text. + */ + public static String TablesPage_tableTooltip; + + /** + * The row count text of a sheet tab's tooltip for a table with one row. + */ + public static String TablesPage_tableTooltipOneRow; + + /** + * The row count text of a sheet tab's tooltip for a table with other than one row; {0} is the number of + * rows. + */ + public static String TablesPage_tableTooltipRows; + + /** + * The column count text of a sheet tab's tooltip for a table with one column. + */ + public static String TablesPage_tableTooltipOneColumn; + + /** + * The column count text of a sheet tab's tooltip for a table with other than one column; {0} is the + * number of columns. + */ + public static String TablesPage_tableTooltipColumns; + + /** + * The tooltip of the sheet tab of a table that only the DTD declares. + */ + public static String TablesPage_declaredOnlyTableTooltip; + + /** + * The column header tooltip of a column the DTD declares that has no values. + */ + public static String ColumnHeaderTooltip_declaredWithoutValues; + + /** + * The column header tooltip of a column that has a default value in the DTD; {0} is the default value. + */ + public static String ColumnHeaderTooltip_defaultValue; + + /** + * The column header tooltip of a pending column. + */ + public static String ColumnHeaderTooltip_pending; + + /** + * The status message after setting cells of a column with a default value to NULL; {0} is the column's + * name and {1} its default value. + */ + public static String SetNull_defaultValue; + + /** + * The status message after setting cells of columns with default values to NULL; {0} lists the + * columns' names. + */ + public static String SetNull_defaultValues; + + /** + * The status message after inserting a blank row that became the first row of a table whose columns + * dbUnit takes from its first row, so the blank row has an empty string in each column of the old first + * row. + */ + public static String InsertRow_newFirstRow; + + /** + * The error for a new cell value whose cell is no longer in the table, because the table changed while + * the cell was being edited. + */ + public static String TableBodyDataProvider_cellNoLongerInTable; + + /** + * The status message after a table changed shape while a cell was being edited, which cancels the edit. + */ + public static String DatasetGrid_editCancelledTableChanged; + + static + { + NLS.initializeMessages(BUNDLE_NAME, Messages.class); + } + + private Messages() + { + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/AddColumnAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/AddColumnAction.java new file mode 100644 index 00000000..3b8cfa32 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/AddColumnAction.java @@ -0,0 +1,109 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import java.util.ArrayList; +import java.util.List; + +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.dbunit.eclipse.dataset.ui.DatasetImages; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.dialogs.DatasetNameValidator; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.jface.dialogs.IInputValidator; +import org.eclipse.jface.dialogs.InputDialog; +import org.eclipse.jface.window.Window; +import org.eclipse.swt.widgets.Shell; + +/** + * Prompts for a name and appends a pending column to the active table. + * + * @since 1.0.0 + */ +public class AddColumnAction extends GridAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public AddColumnAction(final DatasetGridContext context) + { + super(DatasetCommandIds.ADD_COLUMN, context); + setText(Messages.Action_addColumn); + setImageDescriptor(DatasetImages.getImageDescriptor(DatasetImages.IMG_ADD_COLUMN)); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final DatasetTable table = context.getDatasetDocument().requireTable(selection.tableKey()); + final List existingNames = new ArrayList<>(); + for (final DatasetColumn column : table.getColumns()) + { + existingNames.add(column.name()); + } + final String columnName = + openNameDialog(context.getShell(), new DatasetNameValidator(existingNames), table); + if (columnName == null) + { + return; + } + context.executeEdit(() -> context.getDatasetDocument().addColumn(selection.tableKey(), columnName)); + } + + /** + * Opens the dialog that asks for the new column's name. + * + * @param shell The shell to parent the dialog on. + * @param validator The validator for the entered name. + * @param table The table the column would be added to, to decide whether to show the DTD warning. + * @return The entered name, or null when the dialog was cancelled. + */ + String openNameDialog(final Shell shell, final IInputValidator validator, final DatasetTable table) + { + final InputDialog dialog = new InputDialog(shell, Messages.ColumnDialog_addTitle, + nameDialogMessage(table), "", validator); + return dialog.open() == Window.OK ? dialog.getValue() : null; + } + + /** + * Builds the dialog message, warning that dbUnit reads columns from the DTD when the table already + * has columns declared there. + * + * @param table The table the column would be added to. + * @return The dialog message. + */ + static String nameDialogMessage(final DatasetTable table) + { + final boolean hasDtdColumns = table.getColumns().stream().anyMatch(DatasetColumn::declared); + return hasDtdColumns ? Messages.ColumnDialog_nameWithDtdWarning : Messages.ColumnDialog_name; + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return selection.tableKey() != null; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/AddTableAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/AddTableAction.java new file mode 100644 index 00000000..172022c1 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/AddTableAction.java @@ -0,0 +1,93 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import java.util.ArrayList; +import java.util.List; + +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.dbunit.eclipse.dataset.ui.DatasetImages; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.dialogs.AddTableDialog; +import org.dbunit.eclipse.dataset.ui.dialogs.TableNameValidator; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.jface.dialogs.IInputValidator; +import org.eclipse.jface.window.Window; +import org.eclipse.swt.widgets.Shell; + +/** + * Prompts for a name and optional column names, and appends a new table; the new table's tab is selected. + * + * @since 1.0.0 + */ +public class AddTableAction extends GridAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public AddTableAction(final DatasetGridContext context) + { + super(DatasetCommandIds.ADD_TABLE, context); + setText(Messages.Action_addTable); + setImageDescriptor(DatasetImages.getImageDescriptor(DatasetImages.IMG_ADD_TABLE)); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final List existingNames = new ArrayList<>(); + for (final DatasetTable table : context.getDatasetDocument().getModel().getTables()) + { + existingNames.add(table.getName()); + } + final AddTableDialog dialog = openDialog(context.getShell(), + new TableNameValidator(context.getDatasetDocument()::tableKeyOf, existingNames)); + if (dialog == null) + { + return; + } + final String tableName = dialog.getTableName(); + final List columnNames = dialog.getColumnNames(); + context.executeEdit(() -> context.getDatasetDocument().addTable(tableName, columnNames)); + } + + /** + * Opens the dialog that asks for the new table's name and columns. + * + * @param shell The shell to parent the dialog on. + * @param nameValidator The validator for the entered table name. + * @return The dialog, holding the entered name and columns, when confirmed; null when cancelled. + */ + AddTableDialog openDialog(final Shell shell, final IInputValidator nameValidator) + { + final AddTableDialog dialog = new AddTableDialog(shell, nameValidator); + return dialog.open() == Window.OK ? dialog : null; + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return true; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/CellValueChanges.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/CellValueChanges.java new file mode 100644 index 00000000..0a019429 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/CellValueChanges.java @@ -0,0 +1,61 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import java.util.ArrayList; +import java.util.List; + +import org.dbunit.eclipse.dataset.core.edit.CellChange; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.eclipse.swt.graphics.Point; + +/** + * Builds the changes that set cells of a table to one value, for the commands that set the selected cells + * to NULL, to the empty string, or to a pasted value. + * + * @since 1.0.0 + */ +final class CellValueChanges +{ + private CellValueChanges() + { + } + + /** + * Returns the changes that set cells to a value. + * + * @param cells The cells as {@code (columnIndex, rowIndex)} points, such as the cells that the active + * grid has selected. + * @param table The table that the cells belong to. + * @param value The value to set, or null for NULL. + * @return One change for each cell, in the order of the cells. + */ + static List setting(final List cells, final DatasetTable table, final String value) + { + final List changes = new ArrayList<>(); + for (final Point cell : cells) + { + final String columnName = table.getColumns().get(cell.x).name(); + changes.add(new CellChange(cell.y, columnName, value)); + } + return changes; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/CopyAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/CopyAction.java new file mode 100644 index 00000000..606f4a70 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/CopyAction.java @@ -0,0 +1,129 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.dbunit.eclipse.dataset.core.edit.DatasetEditException; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.dbunit.eclipse.dataset.core.tsv.TabSeparatedValues; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.widgets.Text; + +/** + * Copies the selected cells to the clipboard as tab-separated text, each cell with the value dbUnit loads + * for it, so a cell without a value copies its column's DTD default. Rows, columns, or cells that were + * selected separately copy next to each other, without the gaps between them, as in a spreadsheet; that + * takes the same selected columns in every selected row, and a selection without them is refused. + * + * @since 1.0.0 + */ +public final class CopyAction extends GridAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public CopyAction(final DatasetGridContext context) + { + super(context); + setText(Messages.Action_copy); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + copySelectedCellsToClipboard(context); + } + + /** + * Copies the selected cells of the active grid's selection to the clipboard, for reuse by + * {@link CutAction}. + * + * @param context What this needs from the page that hosts the grid. + * @throws DatasetEditException When the selected rows do not all have the same selected columns; the + * clipboard is left as it was. + */ + static void copySelectedCellsToClipboard(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final DatasetTable table = context.getDatasetDocument().requireTable(selection.tableKey()); + final Set selectedCells = new HashSet<>(context.getSelectedCellPositions()); + final List> block = new ArrayList<>(); + for (final int rowIndex : selection.rowIndexes()) + { + block.add(copyRow(table, rowIndex, selection.columnIndexes(), selectedCells)); + } + final String text = TabSeparatedValues.format(block, System.lineSeparator()); + context.writeClipboardText(text); + } + + private static List copyRow(final DatasetTable table, final int rowIndex, + final List columnIndexes, final Set selectedCells) + { + final List line = new ArrayList<>(); + for (final int columnIndex : columnIndexes) + { + final boolean isSelected = selectedCells.contains(new Point(columnIndex, rowIndex)); + if (!isSelected) + { + throw new DatasetEditException(Messages.Copy_selectionNotAligned); + } + line.add(table.getEffectiveValue(rowIndex, columnIndex)); + } + return line; + } + + @Override + protected boolean changesDataset() + { + return false; + } + + @Override + protected boolean isEnabledWhileEditing() + { + return true; + } + + @Override + protected void runWhileEditing(final DatasetGridContext context) + { + final Text text = context.getActiveCellEditorText(); + if (text != null) + { + text.copy(); + } + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return !selection.rowIndexes().isEmpty() && !selection.columnIndexes().isEmpty(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/CutAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/CutAction.java new file mode 100644 index 00000000..73d9192a --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/CutAction.java @@ -0,0 +1,86 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.swt.widgets.Text; + +/** + * Copies the selection, then sets it to NULL; when the selection consists of whole rows, deletes those + * rows instead, since a row cannot be all NULL and cutting rows to move them is the common intent. A + * selection that {@link CopyAction} refuses is refused here too, before anything changes. + * + * @since 1.0.0 + */ +public final class CutAction extends GridAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public CutAction(final DatasetGridContext context) + { + super(context); + setText(Messages.Action_cut); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + CopyAction.copySelectedCellsToClipboard(context); + if (selection.wholeRowsSelected()) + { + final int[] rowIndexes = selection.rowIndexes().stream().mapToInt(Integer::intValue).toArray(); + context.executeEdit( + () -> context.getDatasetDocument().deleteRows(selection.tableKey(), rowIndexes)); + } + else + { + SetNullAction.setSelectedCellsToNull(context); + } + } + + @Override + protected boolean isEnabledWhileEditing() + { + return true; + } + + @Override + protected void runWhileEditing(final DatasetGridContext context) + { + final Text text = context.getActiveCellEditorText(); + if (text != null) + { + text.cut(); + } + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return !selection.rowIndexes().isEmpty() && !selection.columnIndexes().isEmpty(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DatasetCommandIds.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DatasetCommandIds.java new file mode 100644 index 00000000..802d2137 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DatasetCommandIds.java @@ -0,0 +1,123 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +/** + * The identifiers of the commands the Tables page contributes, as declared in {@code plugin.xml}. + * + * @since 1.0.0 + */ +public final class DatasetCommandIds +{ + /** + * The prefix every command id in this plugin starts with. + */ + public static final String PREFIX = "org.dbunit.eclipse.dataset.ui."; + + /** + * Inserts a row above the selection anchor. + */ + public static final String INSERT_ROW_ABOVE = PREFIX + "insertRowAbove"; + + /** + * Inserts a row below the selection anchor. + */ + public static final String INSERT_ROW_BELOW = PREFIX + "insertRowBelow"; + + /** + * Deletes the selected rows. + */ + public static final String DELETE_ROWS = PREFIX + "deleteRows"; + + /** + * Inserts copies of the selected rows directly after them. + */ + public static final String DUPLICATE_ROWS = PREFIX + "duplicateRows"; + + /** + * Moves the selected rows up by one position. + */ + public static final String MOVE_ROWS_UP = PREFIX + "moveRowsUp"; + + /** + * Moves the selected rows down by one position. + */ + public static final String MOVE_ROWS_DOWN = PREFIX + "moveRowsDown"; + + /** + * Adds a pending column to the active table. + */ + public static final String ADD_COLUMN = PREFIX + "addColumn"; + + /** + * Renames the anchor column. + */ + public static final String RENAME_COLUMN = PREFIX + "renameColumn"; + + /** + * Deletes the anchor column. + */ + public static final String DELETE_COLUMN = PREFIX + "deleteColumn"; + + /** + * Appends a new table. + */ + public static final String ADD_TABLE = PREFIX + "addTable"; + + /** + * Renames the active table. + */ + public static final String RENAME_TABLE = PREFIX + "renameTable"; + + /** + * Deletes the active table. + */ + public static final String DELETE_TABLE = PREFIX + "deleteTable"; + + /** + * Sets the selected cells to NULL. + */ + public static final String SET_NULL = PREFIX + "setNull"; + + /** + * Sets the selected cells to the empty string. + */ + public static final String SET_EMPTY_STRING = PREFIX + "setEmptyString"; + + /** + * Copies the top row of the selection down into the other selected rows. + */ + public static final String FILL_DOWN = PREFIX + "fillDown"; + + /** + * Opens the anchor cell's value in a multi-line dialog editor. + */ + public static final String EDIT_CELL_IN_DIALOG = PREFIX + "editCellInDialog"; + + /** + * Selects and reveals the anchor cell's range on the Source page, and switches to it. + */ + public static final String SHOW_IN_SOURCE = PREFIX + "showInSource"; + + private DatasetCommandIds() + { + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DeleteAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DeleteAction.java new file mode 100644 index 00000000..b55c8c9c --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DeleteAction.java @@ -0,0 +1,96 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.swt.widgets.Text; + +/** + * The global Delete action: sets the selected cells to NULL; when the selection consists of whole rows, + * deletes those rows instead, as Delete Rows does, since a row cannot be all NULL. While a cell editor is + * active, it deletes the editor's text selection or the character after the caret. + * + * @since 1.0.0 + */ +public final class DeleteAction extends GridAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public DeleteAction(final DatasetGridContext context) + { + super(context); + setText(Messages.Action_delete); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + if (selection.wholeRowsSelected()) + { + DeleteRowsAction.deleteSelectedRows(context); + } + else + { + SetNullAction.setSelectedCellsToNull(context); + } + } + + @Override + protected boolean isEnabledWhileEditing() + { + return true; + } + + @Override + protected void runWhileEditing(final DatasetGridContext context) + { + final Text text = context.getActiveCellEditorText(); + if (text == null) + { + return; + } + if (text.getSelectionCount() > 0) + { + text.insert(""); + return; + } + final int caretPosition = text.getCaretPosition(); + if (caretPosition < text.getCharCount()) + { + final int codePoint = Character.codePointAt(text.getText(), caretPosition); + final int charCount = Character.charCount(codePoint); + text.setSelection(caretPosition, caretPosition + charCount); + text.insert(""); + } + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return !selection.rowIndexes().isEmpty() && !selection.columnIndexes().isEmpty(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DeleteColumnAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DeleteColumnAction.java new file mode 100644 index 00000000..5c6869a5 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DeleteColumnAction.java @@ -0,0 +1,105 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.dbunit.eclipse.dataset.core.model.DatasetRow; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.dbunit.eclipse.dataset.ui.DatasetImages; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.osgi.util.NLS; +import org.eclipse.swt.widgets.Shell; + +/** + * Confirms, then deletes the anchor column, naming it and the number of values that will be removed. + * + * @since 1.0.0 + */ +public class DeleteColumnAction extends GridAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public DeleteColumnAction(final DatasetGridContext context) + { + super(DatasetCommandIds.DELETE_COLUMN, context); + setText(Messages.Action_deleteColumn); + setImageDescriptor(DatasetImages.getImageDescriptor(DatasetImages.IMG_DELETE_COLUMN)); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final DatasetTable table = context.getDatasetDocument().requireTable(selection.tableKey()); + final DatasetColumn column = table.getColumns().get(selection.anchorColumnIndex()); + final int valueCount = countValues(table, selection.anchorColumnIndex()); + if (!confirmDelete(context.getShell(), column, valueCount)) + { + return; + } + context.executeEdit( + () -> context.getDatasetDocument().deleteColumn(selection.tableKey(), column.name())); + } + + /** + * Opens the dialog that confirms the deletion. + * + * @param shell The shell to parent the dialog on. + * @param column The column that would be deleted. + * @param valueCount The number of values that would be removed. + * @return True when the user confirmed the deletion. + */ + boolean confirmDelete(final Shell shell, final DatasetColumn column, final int valueCount) + { + final String deleteMessage = valueCount == 1 + ? NLS.bind(Messages.DeleteColumnDialog_messageOneValue, column.name()) + : NLS.bind(Messages.DeleteColumnDialog_message, column.name(), valueCount); + final String message = column.declared() ? deleteMessage + ' ' + Messages.DeleteColumnDialog_dtdNote + : deleteMessage; + return MessageDialog.openConfirm(shell, Messages.Action_deleteColumn, message); + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return selection.tableKey() != null && selection.anchorColumnIndex() >= 0 + && selection.anchorColumnIndex() < selection.columnCount(); + } + + private static int countValues(final DatasetTable table, final int columnIndex) + { + int count = 0; + for (final DatasetRow row : table.getRows()) + { + if (row.getValue(columnIndex) != null) + { + count++; + } + } + return count; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DeleteRowsAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DeleteRowsAction.java new file mode 100644 index 00000000..084e0540 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DeleteRowsAction.java @@ -0,0 +1,78 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import org.dbunit.eclipse.dataset.ui.DatasetImages; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; + +/** + * Deletes the selected rows in one undoable change and selects the row left at their position. + * + * @since 1.0.0 + */ +public final class DeleteRowsAction extends GridAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public DeleteRowsAction(final DatasetGridContext context) + { + super(DatasetCommandIds.DELETE_ROWS, context); + setText(Messages.Action_deleteRows); + setImageDescriptor(DatasetImages.getImageDescriptor(DatasetImages.IMG_DELETE_ROWS)); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + deleteSelectedRows(context); + } + + /** + * Deletes the rows the active grid has selected in one undoable change and selects the row left at their + * position, for reuse by {@link DeleteAction}. + * + * @param context What this needs from the page that hosts the grid. + */ + static void deleteSelectedRows(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final int[] rowIndexes = selection.rowIndexes().stream().mapToInt(Integer::intValue).toArray(); + final int columnIndex = Math.max(selection.anchorColumnIndex(), 0); + final int rowIndex = selection.firstRowIndex(); + final boolean applied = context.executeMultiCellEdit(Messages.Action_deleteRows, + () -> context.getDatasetDocument().deleteRows(selection.tableKey(), rowIndexes)); + if (applied) + { + context.selectRegion(columnIndex, rowIndex, 1, 1); + } + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return !selection.rowIndexes().isEmpty(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DeleteTableAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DeleteTableAction.java new file mode 100644 index 00000000..ec08e287 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DeleteTableAction.java @@ -0,0 +1,96 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.osgi.util.NLS; +import org.eclipse.swt.widgets.Shell; + +/** + * Confirms, then deletes the active table, naming it and the number of rows that will be removed; the + * table's tab is removed by the normal reconciliation that follows the edit. + * + * @since 1.0.0 + */ +public class DeleteTableAction extends GridAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public DeleteTableAction(final DatasetGridContext context) + { + super(DatasetCommandIds.DELETE_TABLE, context); + setText(Messages.Action_deleteTable); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final DatasetTable table = context.getDatasetDocument().requireTable(selection.tableKey()); + if (!confirmDelete(context.getShell(), table)) + { + return; + } + context.executeEdit(() -> context.getDatasetDocument().deleteTable(selection.tableKey())); + } + + /** + * Opens the dialog that confirms the deletion. + * + * @param shell The shell to parent the dialog on. + * @param table The table that would be deleted. + * @return True when the user confirmed the deletion. + */ + boolean confirmDelete(final Shell shell, final DatasetTable table) + { + return MessageDialog.openConfirm(shell, Messages.Action_deleteTable, confirmMessage(table)); + } + + /** + * Builds the confirmation message, warning that dbUnit reads tables from the DTD when the table + * exists only there. + * + * @param table The table that would be deleted. + * @return The confirmation message. + */ + static String confirmMessage(final DatasetTable table) + { + final int rowCount = table.getRows().size(); + final String deleteMessage = rowCount == 1 + ? NLS.bind(Messages.DeleteTableDialog_messageOneRow, table.getName()) + : NLS.bind(Messages.DeleteTableDialog_message, table.getName(), rowCount); + return table.isDeclaredOnly() ? deleteMessage + ' ' + Messages.DeleteTableDialog_dtdNote + : deleteMessage; + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return selection.tableKey() != null; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DuplicateRowsAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DuplicateRowsAction.java new file mode 100644 index 00000000..a4c8697c --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DuplicateRowsAction.java @@ -0,0 +1,67 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import org.dbunit.eclipse.dataset.ui.DatasetImages; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; + +/** + * Inserts copies of the selected rows directly after them, in their order, and selects the new block. + * + * @since 1.0.0 + */ +public final class DuplicateRowsAction extends GridAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public DuplicateRowsAction(final DatasetGridContext context) + { + super(DatasetCommandIds.DUPLICATE_ROWS, context); + setText(Messages.Action_duplicateRows); + setImageDescriptor(DatasetImages.getImageDescriptor(DatasetImages.IMG_DUPLICATE_ROWS)); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final int[] rowIndexes = selection.rowIndexes().stream().mapToInt(Integer::intValue).toArray(); + final int newBlockStart = selection.lastRowIndex() + 1; + final boolean applied = context.executeEdit( + () -> context.getDatasetDocument().duplicateRows(selection.tableKey(), rowIndexes)); + if (applied) + { + context.selectRegion(selection.firstColumnIndex(), newBlockStart, selection.columnSpan(), + rowIndexes.length); + } + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return !selection.rowIndexes().isEmpty(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/EditCellInDialogAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/EditCellInDialogAction.java new file mode 100644 index 00000000..1594c96a --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/EditCellInDialogAction.java @@ -0,0 +1,56 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; + +/** + * Opens the anchor cell's value in a multi-line dialog editor, so values with line breaks can be entered. + * + * @since 1.0.0 + */ +public final class EditCellInDialogAction extends GridAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public EditCellInDialogAction(final DatasetGridContext context) + { + super(DatasetCommandIds.EDIT_CELL_IN_DIALOG, context); + setText(Messages.Action_editCellInDialog); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + context.editCellInDialog(); + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return selection.anchorColumnIndex() >= 0 && selection.anchorRowIndex() >= 0; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/FillDownAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/FillDownAction.java new file mode 100644 index 00000000..bfd5fc0e --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/FillDownAction.java @@ -0,0 +1,125 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import org.dbunit.eclipse.dataset.core.edit.CellChange; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.dbunit.eclipse.dataset.ui.DatasetImages; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.swt.graphics.Point; + +/** + * Copies, in each selected column on its own, the topmost selected value into that column's other + * selected cells; a column with a single selected cell copies from the row above instead. + * + * @since 1.0.0 + */ +public final class FillDownAction extends GridAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public FillDownAction(final DatasetGridContext context) + { + super(DatasetCommandIds.FILL_DOWN, context); + setText(Messages.Action_fillDown); + setImageDescriptor(DatasetImages.getImageDescriptor(DatasetImages.IMG_FILL_DOWN)); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final DatasetTable table = context.getDatasetDocument().requireTable(selection.tableKey()); + final List changes = new ArrayList<>(); + for (final Map.Entry> entry : selectedRowsByColumn(context).entrySet()) + { + final int columnIndex = entry.getKey(); + final List selectedRows = entry.getValue(); + final int sourceRowIndex = sourceRowIndex(selectedRows); + if (sourceRowIndex < 0) + { + continue; + } + final String columnName = table.getColumns().get(columnIndex).name(); + final String sourceValue = table.getRows().get(sourceRowIndex).getValue(columnIndex); + for (final int rowIndex : selectedRows) + { + if (rowIndex != sourceRowIndex) + { + changes.add(new CellChange(rowIndex, columnName, sourceValue)); + } + } + } + context.executeMultiCellEdit(Messages.Action_fillDown, + () -> context.getDatasetDocument().setCells(selection.tableKey(), changes)); + } + + /** + * Tells whether any selected cell can be filled, which is so when a selected cell is below the first + * row: a column with several selected cells fills those below its topmost one, and a column with one + * selected cell below the first row fills it from the row above. The answer comes from the snapshot of + * the selection, because it is worked out on every selection event, and the selected cells are too many + * to list each time when the whole table is selected. + */ + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return selection.lastRowIndex() > 0; + } + + /** + * Groups the selected cell positions by column, each column's rows sorted ascending. + */ + private static Map> selectedRowsByColumn(final DatasetGridContext context) + { + final Map> selectedRowsByColumn = new TreeMap<>(); + for (final Point cell : context.getSelectedCellPositions()) + { + selectedRowsByColumn.computeIfAbsent(cell.x, key -> new ArrayList<>()).add(cell.y); + } + for (final List selectedRows : selectedRowsByColumn.values()) + { + Collections.sort(selectedRows); + } + return selectedRowsByColumn; + } + + /** + * Returns a column's fill source row: its own topmost selected row when it has several selected + * cells, or the row above its single selected cell, or -1 when that single selected cell is in row 0. + */ + private static int sourceRowIndex(final List selectedRowsInColumn) + { + return selectedRowsInColumn.size() > 1 ? selectedRowsInColumn.get(0) + : selectedRowsInColumn.get(0) - 1; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/GridAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/GridAction.java new file mode 100644 index 00000000..3ce76c1f --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/GridAction.java @@ -0,0 +1,147 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import org.dbunit.eclipse.dataset.core.edit.DatasetEditException; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.jface.action.Action; + +/** + * A command that acts on the Tables page's active grid: reaches it through a {@link DatasetGridContext}, + * updates its enablement from a {@link GridSelection}, and by default needs an editable page and does + * nothing while a cell editor is active. The dataset document is refreshed before the command runs, so + * that it acts on the text as it is now, also when a change of the text came a moment before and the page + * has not refreshed from it yet. A {@link DatasetEditException} thrown directly by + * {@link #runOnGrid} or {@link #runWhileEditing}, outside an edit run through the context, is reported as + * the status line's error message, as a rejected edit is, instead of propagating. + * + * @since 1.0.0 + */ +public abstract class GridAction extends Action +{ + private final DatasetGridContext context; + + /** + * Creates a grid action bound to a command id. + * + * @param commandId The command id to report through {@code getActionDefinitionId()}. + * @param context What this action needs from the page that hosts the grid. + */ + protected GridAction(final String commandId, final DatasetGridContext context) + { + this.context = context; + setActionDefinitionId(commandId); + } + + /** + * Creates a grid action with no command id, for one installed as a global retargetable action instead + * of a workbench command. + * + * @param context What this action needs from the page that hosts the grid. + */ + protected GridAction(final DatasetGridContext context) + { + this.context = context; + } + + @Override + public final void run() + { + try + { + if (context.hasActiveCellEditor()) + { + if (isEnabledWhileEditing()) + { + runWhileEditing(context); + } + return; + } + context.getDatasetDocument().refresh(); + runOnGrid(context); + } + catch (final DatasetEditException e) + { + context.setStatusErrorMessage(e.getMessage()); + } + } + + /** + * Refreshes this action's enablement from the page's editable state and the active grid's selection. + * + * @param selection The active grid's current selection. + */ + public final void update(final GridSelection selection) + { + final boolean pageAllowsThis = context.isEditable() || !changesDataset(); + final boolean editingAllowsThis = !context.hasActiveCellEditor() || isEnabledWhileEditing(); + setEnabled(pageAllowsThis && editingAllowsThis && isEnabledFor(selection)); + } + + /** + * Runs this command's edit on the active grid. + * + * @param context What this action needs from the page that hosts the grid. + */ + protected abstract void runOnGrid(DatasetGridContext context); + + /** + * Returns whether this command applies to a selection, with the page's editable state and the active + * cell editor already accounted for. + * + * @param selection The active grid's current selection. + * @return True when this command should be enabled. + */ + protected abstract boolean isEnabledFor(GridSelection selection); + + /** + * Returns whether this command changes the dataset, and so needs an editable page. + * + * @return True by default, and false for a command that only reads or selects cells. + */ + protected boolean changesDataset() + { + return true; + } + + /** + * Returns whether this command stays enabled and dispatches to {@link #runWhileEditing} while a cell + * editor is active, instead of doing nothing. + * + * @return True when this command has an editing-mode behavior; false by default. + */ + protected boolean isEnabledWhileEditing() + { + return false; + } + + /** + * Runs this command's fallback while a cell editor is active, for a command whose + * {@link #isEnabledWhileEditing()} returns true. The default does nothing. + * + * @param context What this action needs from the page that hosts the grid. + */ + protected void runWhileEditing(final DatasetGridContext context) + { + // Nothing to do by default: a command with an editing-mode behavior overrides this. + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/InsertRowAboveAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/InsertRowAboveAction.java new file mode 100644 index 00000000..90644c8b --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/InsertRowAboveAction.java @@ -0,0 +1,53 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import org.dbunit.eclipse.dataset.ui.DatasetImages; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; + +/** + * Inserts a blank row above the selection anchor and selects it. With no anchor, it goes before the first + * row. + * + * @since 1.0.0 + */ +public final class InsertRowAboveAction extends InsertRowAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public InsertRowAboveAction(final DatasetGridContext context) + { + super(DatasetCommandIds.INSERT_ROW_ABOVE, context); + setText(Messages.Action_insertRowAbove); + setImageDescriptor(DatasetImages.getImageDescriptor(DatasetImages.IMG_INSERT_ROW_ABOVE)); + } + + @Override + int insertionIndex(final GridSelection selection) + { + return Math.max(selection.anchorRowIndex(), 0); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/InsertRowAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/InsertRowAction.java new file mode 100644 index 00000000..6555f6c8 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/InsertRowAction.java @@ -0,0 +1,112 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import java.util.Optional; + +import org.dbunit.eclipse.dataset.core.model.DatasetRow; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; + +/** + * Inserts a blank row next to the selection anchor and selects it. A blank row has an empty string in its + * first column. When it becomes the first row of a table whose columns dbUnit takes from its first row, it + * has an empty string in every column of the old first row too, and the status line says so. + * + * @since 1.0.0 + */ +abstract class InsertRowAction extends GridAction +{ + /** + * Creates the action. + * + * @param commandId The command id of the action. + * @param context What this action needs from the page that hosts the grid. + */ + InsertRowAction(final String commandId, final DatasetGridContext context) + { + super(commandId, context); + } + + /** + * Returns the index that the blank row is inserted at. + * + * @param selection The active grid's current selection. + * @return The index of the row that the blank row goes before, which may be the row count. + */ + abstract int insertionIndex(GridSelection selection); + + @Override + protected final void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final int rowIndex = insertionIndex(selection); + final int columnIndex = Math.max(selection.anchorColumnIndex(), 0); + final boolean applied = context.executeEdit( + () -> context.getDatasetDocument().insertBlankRow(selection.tableKey(), rowIndex)); + if (applied) + { + context.selectRegion(columnIndex, rowIndex, 1, 1); + reportNewFirstRow(context, selection.tableKey(), rowIndex); + } + } + + @Override + protected final boolean isEnabledFor(final GridSelection selection) + { + return selection.tableKey() != null && selection.columnCount() > 0; + } + + /** + * Tells the user, on the status line, that the blank row has an empty string in more than its first + * column, which it has when it became the first row of a table whose columns dbUnit takes from its + * first row. + */ + private static void reportNewFirstRow(final DatasetGridContext context, final String tableKey, + final int rowIndex) + { + final Optional table = context.getDatasetDocument().getModel().findTable(tableKey); + if (table.isEmpty() || rowIndex >= table.get().getRows().size()) + { + return; + } + final DatasetRow row = table.get().getRows().get(rowIndex); + if (valueCount(row) > 1) + { + context.setStatusMessage(Messages.InsertRow_newFirstRow); + } + } + + private static int valueCount(final DatasetRow row) + { + int count = 0; + for (final String value : row.getValues()) + { + if (value != null) + { + count++; + } + } + return count; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/InsertRowBelowAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/InsertRowBelowAction.java new file mode 100644 index 00000000..bd95e045 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/InsertRowBelowAction.java @@ -0,0 +1,56 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import org.dbunit.eclipse.dataset.ui.DatasetImages; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; + +/** + * Inserts a blank row below the selection anchor and selects it. With no anchor, it goes after the last row. + * + * @since 1.0.0 + */ +public final class InsertRowBelowAction extends InsertRowAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public InsertRowBelowAction(final DatasetGridContext context) + { + super(DatasetCommandIds.INSERT_ROW_BELOW, context); + setText(Messages.Action_insertRowBelow); + setImageDescriptor(DatasetImages.getImageDescriptor(DatasetImages.IMG_INSERT_ROW_BELOW)); + } + + @Override + int insertionIndex(final GridSelection selection) + { + if (selection.anchorRowIndex() < 0) + { + return selection.rowCount(); + } + return selection.anchorRowIndex() + 1; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/MoveRowsAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/MoveRowsAction.java new file mode 100644 index 00000000..270a489a --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/MoveRowsAction.java @@ -0,0 +1,72 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; + +/** + * Moves a contiguous block of selected rows by one position and keeps it selected. + * + * @since 1.0.0 + */ +abstract class MoveRowsAction extends GridAction +{ + /** + * The change of position that moves a block of rows up. + */ + static final int UP = -1; + + /** + * The change of position that moves a block of rows down. + */ + static final int DOWN = 1; + + private final int delta; + + /** + * Creates the action. + * + * @param commandId The command id of the action. + * @param context What this action needs from the page that hosts the grid. + * @param delta The number of positions that the block moves, {@link #UP} or {@link #DOWN}. + */ + MoveRowsAction(final String commandId, final DatasetGridContext context, final int delta) + { + super(commandId, context); + this.delta = delta; + } + + @Override + protected final void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final int rowCount = selection.rowIndexes().size(); + final int newFirstRowIndex = selection.firstRowIndex() + delta; + final boolean applied = context.executeEdit(() -> context.getDatasetDocument() + .moveRows(selection.tableKey(), selection.firstRowIndex(), rowCount, delta)); + if (applied) + { + context.selectRegion(selection.firstColumnIndex(), newFirstRowIndex, selection.columnSpan(), + rowCount); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/MoveRowsDownAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/MoveRowsDownAction.java new file mode 100644 index 00000000..e01771bd --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/MoveRowsDownAction.java @@ -0,0 +1,51 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; + +/** + * Moves a contiguous block of selected rows down by one position and keeps it selected. + * + * @since 1.0.0 + */ +public final class MoveRowsDownAction extends MoveRowsAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public MoveRowsDownAction(final DatasetGridContext context) + { + super(DatasetCommandIds.MOVE_ROWS_DOWN, context, DOWN); + setText(Messages.Action_moveRowsDown); + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return selection.isContiguousRowSelection() + && selection.lastRowIndex() < selection.rowCount() - 1; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/MoveRowsUpAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/MoveRowsUpAction.java new file mode 100644 index 00000000..39ca57f8 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/MoveRowsUpAction.java @@ -0,0 +1,50 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; + +/** + * Moves a contiguous block of selected rows up by one position and keeps it selected. + * + * @since 1.0.0 + */ +public final class MoveRowsUpAction extends MoveRowsAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public MoveRowsUpAction(final DatasetGridContext context) + { + super(DatasetCommandIds.MOVE_ROWS_UP, context, UP); + setText(Messages.Action_moveRowsUp); + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return selection.isContiguousRowSelection() && selection.firstRowIndex() > 0; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/PasteAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/PasteAction.java new file mode 100644 index 00000000..99eb06f0 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/PasteAction.java @@ -0,0 +1,169 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import java.util.ArrayList; +import java.util.List; + +import org.dbunit.eclipse.dataset.core.edit.CellChange; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.dbunit.eclipse.dataset.core.tsv.TabSeparatedValues; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.osgi.util.NLS; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.widgets.Text; + +/** + * Pastes tab-separated text from the clipboard at the selection's top-left cell, or at column 0, row 0 of + * a table with no rows yet: a single value fills every selected cell; otherwise the block is pasted at the + * anchor, with columns beyond the last column ignored and rows beyond the last row appended, all as one + * undo step. A row to append that would have no value, such as a blank line, is skipped, and the status + * line says how many columns were ignored and how many rows were skipped. An unquoted empty pasted field + * becomes NULL; a quoted empty field ({@code ""}) becomes the empty string. + * + * @since 1.0.0 + */ +public final class PasteAction extends GridAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public PasteAction(final DatasetGridContext context) + { + super(context); + setText(Messages.Action_paste); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final String text = context.readClipboardText(); + if (text == null || text.isEmpty()) + { + return; + } + final List> parsedRows = TabSeparatedValues.parse(text); + if (parsedRows.isEmpty()) + { + return; + } + final GridSelection selection = context.getSelection(); + final DatasetTable table = context.getDatasetDocument().requireTable(selection.tableKey()); + final boolean singleValue = parsedRows.size() == 1 && parsedRows.get(0).size() == 1; + if (singleValue && selection.hasMultipleCells()) + { + fillSelection(context, selection, table, parsedRows.get(0).get(0)); + return; + } + pasteBlock(context, selection, table, parsedRows); + } + + private void fillSelection(final DatasetGridContext context, final GridSelection selection, + final DatasetTable table, final String value) + { + final List selectedCells = context.getSelectedCellPositions(); + final List changes = CellValueChanges.setting(selectedCells, table, value); + context.executeMultiCellEdit(Messages.Action_paste, + () -> context.getDatasetDocument().setCells(selection.tableKey(), changes)); + } + + private void pasteBlock(final DatasetGridContext context, final GridSelection selection, + final DatasetTable table, final List> parsedRows) + { + final int rowCount = table.getRows().size(); + final boolean noValidAnchor = selection.firstRowIndex() < 0 || selection.firstColumnIndex() < 0; + if (rowCount > 0 && noValidAnchor) + { + return; + } + final int anchorRowIndex = Math.max(0, selection.firstRowIndex()); + final int anchorColumnIndex = Math.max(0, selection.firstColumnIndex()); + final PastePlan plan = PastePlan.of(table, anchorRowIndex, anchorColumnIndex, parsedRows); + if (plan.pastedRowCount() > 0) + { + final boolean edited = context.executeMultiCellEdit(Messages.Action_paste, + () -> context.getDatasetDocument().setCellsAndAppendRows(selection.tableKey(), + plan.changes(), plan.appendedRows())); + if (!edited) + { + return; + } + context.selectRegion(anchorColumnIndex, anchorRowIndex, plan.pastedColumnCount(), + plan.pastedRowCount()); + } + reportLeftOut(context, plan); + } + + private static void reportLeftOut(final DatasetGridContext context, final PastePlan plan) + { + final List notes = new ArrayList<>(); + if (plan.ignoredColumnCount() > 0) + { + notes.add(countMessage(plan.ignoredColumnCount(), Messages.Paste_ignoredColumn, + Messages.Paste_ignoredColumns)); + } + if (plan.skippedRowCount() > 0) + { + notes.add(countMessage(plan.skippedRowCount(), Messages.Paste_skippedRow, + Messages.Paste_skippedRows)); + } + if (!notes.isEmpty()) + { + context.setStatusMessage(String.join(" ", notes)); + } + } + + private static String countMessage(final int count, final String singularMessage, + final String pluralMessage) + { + return NLS.bind(count == 1 ? singularMessage : pluralMessage, count); + } + + @Override + protected boolean isEnabledWhileEditing() + { + return true; + } + + @Override + protected void runWhileEditing(final DatasetGridContext context) + { + final Text text = context.getActiveCellEditorText(); + if (text != null) + { + text.paste(); + } + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + if (selection.rowCount() == 0) + { + return selection.columnCount() > 0; + } + return !selection.rowIndexes().isEmpty() && !selection.columnIndexes().isEmpty(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/PastePlan.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/PastePlan.java new file mode 100644 index 00000000..7762e90c --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/PastePlan.java @@ -0,0 +1,140 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.dbunit.eclipse.dataset.core.edit.CellChange; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; + +/** + * Plans where the rows of pasted text go in a table: the new values of the cells of the existing rows, and + * the rows to append after the last row. A pasted value in a column beyond the table's last column has no + * cell to go to, so the plan leaves it out. A row to append that has no value, such as a blank line, cannot + * be a row of a flat XML dataset, so the plan skips it. + * + * @param changes The new values of the cells of the existing rows; a null value makes a cell NULL. + * @param appendedRows The rows to append after the last row, each with one value for every column of the + * table, in column order, and with at least one value that is not null; a null value is + * a column the new row has no value for. + * @param pastedRowCount The number of rows of the pasted text that the paste writes, as changes to existing + * rows or as appended rows. + * @param pastedColumnCount The number of columns of the pasted text. + * @param skippedRowCount The number of rows to append that the plan skipped because they have no value. + * @param ignoredColumnCount The number of pasted columns beyond the table's last column. + */ +record PastePlan(List changes, List> appendedRows, int pastedRowCount, + int pastedColumnCount, int skippedRowCount, int ignoredColumnCount) +{ + /** + * Creates a plan with its own unmodifiable copies of the changes and of the appended rows, so that + * neither the caller that built them nor a caller of the accessors can change the plan. + */ + PastePlan + { + changes = List.copyOf(changes); + appendedRows = List.copyOf(appendedRows); + } + + /** + * Plans a paste of parsed text into a table. + * + * @param table The table to paste into. + * @param anchorRowIndex The index of the row that the first pasted row goes to; the rows from the row + * count on are appended. + * @param anchorColumnIndex The index of the column that the first pasted column goes to. + * @param parsedRows The rows of pasted values, all of the same width, at least one. + * @return The plan. + */ + static PastePlan of(final DatasetTable table, final int anchorRowIndex, final int anchorColumnIndex, + final List> parsedRows) + { + final int rowCount = table.getRows().size(); + final int columnCount = table.getColumns().size(); + final int pastedColumnCount = parsedRows.get(0).size(); + final int ignoredColumnCount = Math.max(0, anchorColumnIndex + pastedColumnCount - columnCount); + final List changes = new ArrayList<>(); + final List> appendedRows = new ArrayList<>(); + int skippedRowCount = 0; + for (int sourceRowIndex = 0; sourceRowIndex < parsedRows.size(); sourceRowIndex++) + { + final int targetRowIndex = anchorRowIndex + sourceRowIndex; + final List sourceRow = parsedRows.get(sourceRowIndex); + if (targetRowIndex < rowCount) + { + addExistingRowChanges(table, changes, targetRowIndex, anchorColumnIndex, sourceRow); + } + else + { + final List appendedRow = buildAppendedRow(anchorColumnIndex, columnCount, sourceRow); + if (hasValue(appendedRow)) + { + appendedRows.add(appendedRow); + } + else + { + skippedRowCount++; + } + } + } + final int pastedRowCount = parsedRows.size() - skippedRowCount; + return new PastePlan(changes, appendedRows, pastedRowCount, pastedColumnCount, skippedRowCount, + ignoredColumnCount); + } + + private static void addExistingRowChanges(final DatasetTable table, final List changes, + final int targetRowIndex, final int anchorColumnIndex, final List sourceRow) + { + final int columnCount = table.getColumns().size(); + for (int sourceColumnIndex = 0; sourceColumnIndex < sourceRow.size(); sourceColumnIndex++) + { + final int targetColumnIndex = anchorColumnIndex + sourceColumnIndex; + if (targetColumnIndex >= columnCount) + { + continue; + } + final String value = sourceRow.get(sourceColumnIndex); + changes.add(new CellChange(targetRowIndex, table.getColumns().get(targetColumnIndex).name(), + value)); + } + } + + private static List buildAppendedRow(final int anchorColumnIndex, final int columnCount, + final List sourceRow) + { + final List newRow = new ArrayList<>(); + for (int columnIndex = 0; columnIndex < columnCount; columnIndex++) + { + final int sourceColumnIndex = columnIndex - anchorColumnIndex; + final String value = sourceColumnIndex >= 0 && sourceColumnIndex < sourceRow.size() + ? sourceRow.get(sourceColumnIndex) : null; + newRow.add(value); + } + return newRow; + } + + private static boolean hasValue(final List row) + { + return row.stream().anyMatch(Objects::nonNull); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/RenameColumnAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/RenameColumnAction.java new file mode 100644 index 00000000..bb7b02d7 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/RenameColumnAction.java @@ -0,0 +1,117 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import java.util.ArrayList; +import java.util.List; + +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.dialogs.DatasetNameValidator; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.jface.dialogs.IInputValidator; +import org.eclipse.jface.dialogs.InputDialog; +import org.eclipse.jface.window.Window; +import org.eclipse.swt.widgets.Shell; + +/** + * Prompts for a new name and renames the anchor column. + * + * @since 1.0.0 + */ +public class RenameColumnAction extends GridAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public RenameColumnAction(final DatasetGridContext context) + { + super(DatasetCommandIds.RENAME_COLUMN, context); + setText(Messages.Action_renameColumn); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final DatasetTable table = context.getDatasetDocument().requireTable(selection.tableKey()); + final DatasetColumn currentColumn = table.getColumns().get(selection.anchorColumnIndex()); + final String currentName = currentColumn.name(); + final String currentKey = currentColumn.key(); + final List existingNames = new ArrayList<>(); + for (final DatasetColumn column : table.getColumns()) + { + if (!column.key().equals(currentKey)) + { + existingNames.add(column.name()); + } + } + final String newName = openNameDialog(context.getShell(), currentName, + new DatasetNameValidator(existingNames), currentColumn); + if (newName == null || newName.equals(currentName)) + { + return; + } + context.executeEdit(() -> context.getDatasetDocument().renameColumn(selection.tableKey(), + currentName, newName)); + } + + /** + * Opens the dialog that asks for the column's new name. + * + * @param shell The shell to parent the dialog on. + * @param currentName The column's current name, shown as the dialog's initial value. + * @param validator The validator for the entered name. + * @param column The column being renamed, to decide whether to show the DTD warning. + * @return The entered name, or null when the dialog was cancelled. + */ + String openNameDialog(final Shell shell, final String currentName, final IInputValidator validator, + final DatasetColumn column) + { + final InputDialog dialog = new InputDialog(shell, Messages.ColumnDialog_renameTitle, + nameDialogMessage(column), currentName, validator); + return dialog.open() == Window.OK ? dialog.getValue() : null; + } + + /** + * Builds the dialog message, warning that dbUnit reads columns from the DTD when the column being + * renamed is declared there. + * + * @param column The column being renamed. + * @return The dialog message. + */ + static String nameDialogMessage(final DatasetColumn column) + { + return column.declared() ? Messages.ColumnDialog_renameNameWithDtdWarning + : Messages.ColumnDialog_name; + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return selection.tableKey() != null && selection.anchorColumnIndex() >= 0 + && selection.anchorColumnIndex() < selection.columnCount(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/RenameTableAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/RenameTableAction.java new file mode 100644 index 00000000..c3623d07 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/RenameTableAction.java @@ -0,0 +1,118 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import java.util.ArrayList; +import java.util.List; + +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.dialogs.TableNameValidator; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.jface.dialogs.IInputValidator; +import org.eclipse.jface.dialogs.InputDialog; +import org.eclipse.jface.window.Window; +import org.eclipse.swt.widgets.Shell; + +/** + * Prompts for a new name and renames the active table, keeping its tab and selection. + * + * @since 1.0.0 + */ +public class RenameTableAction extends GridAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public RenameTableAction(final DatasetGridContext context) + { + super(DatasetCommandIds.RENAME_TABLE, context); + setText(Messages.Action_renameTable); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final DatasetTable currentTable = context.getDatasetDocument().requireTable(selection.tableKey()); + final String currentName = currentTable.getName(); + final List existingNames = new ArrayList<>(); + for (final DatasetTable table : context.getDatasetDocument().getModel().getTables()) + { + if (!table.getKey().equals(currentTable.getKey())) + { + existingNames.add(table.getName()); + } + } + final String newName = openNameDialog(context.getShell(), currentName, + new TableNameValidator(context.getDatasetDocument()::tableKeyOf, existingNames), + currentTable); + if (newName == null || newName.equals(currentName)) + { + return; + } + context.executeEdit(() -> context.getDatasetDocument().renameTable(selection.tableKey(), newName)); + } + + /** + * Opens the dialog that asks for the table's new name. + * + * @param shell The shell to parent the dialog on. + * @param currentName The table's current name, shown as the dialog's initial value. + * @param validator The validator for the entered name. + * @param table The table being renamed, to decide whether to show the DTD warning. + * @return The entered name, or null when the dialog was cancelled. + */ + String openNameDialog(final Shell shell, final String currentName, final IInputValidator validator, + final DatasetTable table) + { + final InputDialog dialog = new InputDialog(shell, Messages.TableDialog_renameTitle, + nameDialogMessage(table), currentName, validator); + return dialog.open() == Window.OK ? dialog.getValue() : null; + } + + /** + * Builds the dialog message, warning that dbUnit reads tables from the DTD when the table exists only + * there, and when a DTD file declares it, because the editor renames the table in the DOCTYPE's + * internal subset but does not change a DTD file. + * + * @param table The table being renamed. + * @return The dialog message. + */ + static String nameDialogMessage(final DatasetTable table) + { + if (table.isDeclaredOnly()) + { + return Messages.TableDialog_nameWithDtdWarning; + } + return table.isDeclaredInExternalDtd() ? Messages.TableDialog_renameNameWithDtdWarning + : Messages.TableDialog_name; + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return selection.tableKey() != null; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/SelectAllAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/SelectAllAction.java new file mode 100644 index 00000000..26551dcf --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/SelectAllAction.java @@ -0,0 +1,80 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.swt.widgets.Text; + +/** + * The global Select All action: selects every cell of the active grid, or, while a cell editor is active, + * selects all of the editor's text. + * + * @since 1.0.0 + */ +public final class SelectAllAction extends GridAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public SelectAllAction(final DatasetGridContext context) + { + super(context); + setText(Messages.Action_selectAll); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + context.selectAll(); + } + + @Override + protected boolean changesDataset() + { + return false; + } + + @Override + protected boolean isEnabledWhileEditing() + { + return true; + } + + @Override + protected void runWhileEditing(final DatasetGridContext context) + { + final Text text = context.getActiveCellEditorText(); + if (text != null) + { + text.selectAll(); + } + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return selection.rowCount() > 0 && selection.columnCount() > 0; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/SetEmptyStringAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/SetEmptyStringAction.java new file mode 100644 index 00000000..bb408826 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/SetEmptyStringAction.java @@ -0,0 +1,64 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import java.util.List; + +import org.dbunit.eclipse.dataset.core.edit.CellChange; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; + +/** + * Sets every selected cell to the empty string. + * + * @since 1.0.0 + */ +public final class SetEmptyStringAction extends GridAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public SetEmptyStringAction(final DatasetGridContext context) + { + super(DatasetCommandIds.SET_EMPTY_STRING, context); + setText(Messages.Action_setEmptyString); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final DatasetTable table = context.getDatasetDocument().requireTable(selection.tableKey()); + final List changes = + CellValueChanges.setting(context.getSelectedCellPositions(), table, ""); + context.executeEdit(() -> context.getDatasetDocument().setCells(selection.tableKey(), changes)); + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return !selection.rowIndexes().isEmpty() && !selection.columnIndexes().isEmpty(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/SetNullAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/SetNullAction.java new file mode 100644 index 00000000..90ee2456 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/SetNullAction.java @@ -0,0 +1,126 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import org.dbunit.eclipse.dataset.core.edit.CellChange; +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.dbunit.eclipse.dataset.ui.DatasetImages; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.osgi.util.NLS; +import org.eclipse.swt.graphics.Point; + +/** + * Sets every selected cell to NULL, and tells the user when a cell cannot be NULL because the DTD gives its + * column a default value. + * + * @since 1.0.0 + */ +public final class SetNullAction extends GridAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public SetNullAction(final DatasetGridContext context) + { + super(DatasetCommandIds.SET_NULL, context); + setText(Messages.Action_setNull); + setImageDescriptor(DatasetImages.getImageDescriptor(DatasetImages.IMG_SET_NULL)); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + setSelectedCellsToNull(context); + } + + /** + * Sets every cell the active grid has selected to NULL, for reuse by {@link CutAction} and + * {@link DeleteAction}. + * + * @param context What this needs from the page that hosts the grid. + */ + static void setSelectedCellsToNull(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final DatasetTable table = context.getDatasetDocument().requireTable(selection.tableKey()); + final List cells = context.getSelectedCellPositions(); + final List changes = CellValueChanges.setting(cells, table, null); + final boolean edited = context + .executeEdit(() -> context.getDatasetDocument().setCells(selection.tableKey(), changes)); + if (edited) + { + reportColumnsWithDefaultValues(context, table, cells); + } + } + + /** + * Tells the user, on the status line, that a cell cannot be NULL where the DTD gives its column a default + * value, because dbUnit loads the default for a row without the attribute. + */ + private static void reportColumnsWithDefaultValues(final DatasetGridContext context, + final DatasetTable table, final List cells) + { + final Set defaultedColumns = new LinkedHashSet<>(); + for (final Point cell : cells) + { + final DatasetColumn column = table.getColumns().get(cell.x); + if (column.hasDefaultValue()) + { + defaultedColumns.add(column); + } + } + if (defaultedColumns.isEmpty()) + { + return; + } + final DatasetColumn first = defaultedColumns.iterator().next(); + final String message = defaultedColumns.size() == 1 + ? NLS.bind(Messages.SetNull_defaultValue, first.name(), first.defaultValue()) + : NLS.bind(Messages.SetNull_defaultValues, columnNames(defaultedColumns)); + context.setStatusMessage(message); + } + + private static String columnNames(final Set columns) + { + final List names = new ArrayList<>(); + for (final DatasetColumn column : columns) + { + names.add(column.name()); + } + return String.join(", ", names); + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return !selection.rowIndexes().isEmpty() && !selection.columnIndexes().isEmpty(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/ShowInSourceAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/ShowInSourceAction.java new file mode 100644 index 00000000..9ba8ce6c --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/ShowInSourceAction.java @@ -0,0 +1,62 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; + +/** + * Selects and reveals the anchor cell's range on the Source page, and switches to it. + * + * @since 1.0.0 + */ +public final class ShowInSourceAction extends GridAction +{ + /** + * Creates the action. + * + * @param context What this action needs from the page that hosts the grid. + */ + public ShowInSourceAction(final DatasetGridContext context) + { + super(DatasetCommandIds.SHOW_IN_SOURCE, context); + setText(Messages.Action_showInSource); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + context.showInSource(); + } + + @Override + protected boolean changesDataset() + { + return false; + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return selection.anchorColumnIndex() >= 0 && selection.anchorRowIndex() >= 0; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/dialogs/AddTableDialog.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/dialogs/AddTableDialog.java new file mode 100644 index 00000000..d6131d09 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/dialogs/AddTableDialog.java @@ -0,0 +1,150 @@ +/* + * + * 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.eclipse.dataset.ui.dialogs; + +import java.util.ArrayList; +import java.util.List; + +import org.dbunit.eclipse.dataset.ui.Messages; +import org.eclipse.jface.dialogs.Dialog; +import org.eclipse.jface.dialogs.IInputValidator; +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.swt.SWT; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; + +/** + * Prompts for a new table's name and an optional comma-separated list of column names. + * + * @since 1.0.0 + */ +public class AddTableDialog extends Dialog +{ + private final IInputValidator nameValidator; + + private Text nameText; + + private Text columnsText; + + private String tableName; + + private List columnNames = List.of(); + + /** + * Creates the dialog. + * + * @param parentShell The shell to parent this dialog on. + * @param nameValidator The validator for the entered table name. + */ + public AddTableDialog(final Shell parentShell, final IInputValidator nameValidator) + { + super(parentShell); + this.nameValidator = nameValidator; + } + + @Override + protected void configureShell(final Shell newShell) + { + super.configureShell(newShell); + newShell.setText(Messages.TableDialog_addTitle); + } + + @Override + protected Control createDialogArea(final Composite parent) + { + final Composite composite = (Composite) super.createDialogArea(parent); + composite.setLayout(new GridLayout(2, false)); + + final Label nameLabel = new Label(composite, SWT.NONE); + nameLabel.setText(Messages.TableDialog_name); + nameText = new Text(composite, SWT.BORDER); + nameText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + + final Label columnsLabel = new Label(composite, SWT.NONE); + columnsLabel.setText(Messages.TableDialog_columnNames); + columnsLabel.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, false, false, 2, 1)); + columnsText = new Text(composite, SWT.BORDER); + columnsText.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 2, 1)); + + return composite; + } + + @Override + protected void okPressed() + { + final String enteredName = nameText.getText().trim(); + final String validationMessage = nameValidator.isValid(enteredName); + if (validationMessage != null) + { + MessageDialog.openError(getShell(), Messages.TableDialog_addTitle, validationMessage); + return; + } + final List enteredColumnNames = parseColumnNames(columnsText.getText()); + final String columnValidationMessage = new ColumnNamesValidator().isValid(enteredColumnNames); + if (columnValidationMessage != null) + { + MessageDialog.openError(getShell(), Messages.TableDialog_addTitle, columnValidationMessage); + return; + } + tableName = enteredName; + columnNames = enteredColumnNames; + super.okPressed(); + } + + /** + * Returns the entered table name. + * + * @return The table name; only meaningful after the dialog closes with {@code Window.OK}. + */ + public String getTableName() + { + return tableName; + } + + /** + * Returns the entered column names. + * + * @return The unmodifiable column names, in the entered order; empty when none were entered. + */ + public List getColumnNames() + { + return List.copyOf(columnNames); + } + + private static List parseColumnNames(final String text) + { + final List names = new ArrayList<>(); + for (final String part : text.split(",", -1)) + { + final String trimmed = part.trim(); + if (!trimmed.isEmpty()) + { + names.add(trimmed); + } + } + return names; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/dialogs/ColumnNamesValidator.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/dialogs/ColumnNamesValidator.java new file mode 100644 index 00000000..8f3e24d4 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/dialogs/ColumnNamesValidator.java @@ -0,0 +1,67 @@ +/* + * + * 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.eclipse.dataset.ui.dialogs; + +import java.util.ArrayList; +import java.util.List; + +import org.dbunit.eclipse.dataset.core.flatxml.XmlNames; +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.eclipse.osgi.util.NLS; + +/** + * Validates a table's optional column names: each must be a valid XML name, and no two may have the same + * key, which is how the dataset model tells columns apart, see {@link DatasetColumn#keyOf}. + * + * @since 1.0.0 + */ +public final class ColumnNamesValidator +{ + /** + * Validates a list of column names. + * + * @param columnNames The column names to validate, in the entered order. + * @return An error message naming the first invalid or duplicate column; null when every name is a + * valid, unique XML name. + */ + public String isValid(final List columnNames) + { + final List seenColumnNames = new ArrayList<>(); + for (final String columnName : columnNames) + { + if (!XmlNames.isValidName(columnName)) + { + return NLS.bind(Messages.NameValidator_invalid, columnName); + } + final String columnKey = DatasetColumn.keyOf(columnName); + for (final String seenColumnName : seenColumnNames) + { + if (DatasetColumn.keyOf(seenColumnName).equals(columnKey)) + { + return NLS.bind(Messages.NameValidator_used, columnName); + } + } + seenColumnNames.add(columnName); + } + return null; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/dialogs/DatasetNameValidator.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/dialogs/DatasetNameValidator.java new file mode 100644 index 00000000..1b799f67 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/dialogs/DatasetNameValidator.java @@ -0,0 +1,74 @@ +/* + * + * 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.eclipse.dataset.ui.dialogs; + +import java.util.Collection; +import java.util.List; + +import org.dbunit.eclipse.dataset.core.flatxml.XmlNames; +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.eclipse.jface.dialogs.IInputValidator; +import org.eclipse.osgi.util.NLS; + +/** + * Validates a column name: it must be a valid XML name, and not already used. + * + * @since 1.0.0 + */ +public final class DatasetNameValidator implements IInputValidator +{ + private final Collection existingNames; + + /** + * Creates a validator. + * + * @param existingNames The names already in use; the entered name must not have the same key as any + * of them, which is how the dataset model tells columns apart, see + * {@link DatasetColumn#keyOf}. The validator keeps its own copy of them. + */ + public DatasetNameValidator(final Collection existingNames) + { + this.existingNames = List.copyOf(existingNames); + } + + @Override + public String isValid(final String newText) + { + if (newText == null || newText.isEmpty()) + { + return Messages.NameValidator_empty; + } + if (!XmlNames.isValidName(newText)) + { + return NLS.bind(Messages.NameValidator_invalid, newText); + } + final String newKey = DatasetColumn.keyOf(newText); + for (final String existingName : existingNames) + { + if (DatasetColumn.keyOf(existingName).equals(newKey)) + { + return NLS.bind(Messages.NameValidator_used, newText); + } + } + return null; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/dialogs/TableNameValidator.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/dialogs/TableNameValidator.java new file mode 100644 index 00000000..20ed6f23 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/dialogs/TableNameValidator.java @@ -0,0 +1,85 @@ +/* + * + * 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.eclipse.dataset.ui.dialogs; + +import java.util.Collection; +import java.util.List; +import java.util.function.Function; + +import org.dbunit.eclipse.dataset.core.flatxml.XmlNames; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.eclipse.jface.dialogs.IInputValidator; +import org.eclipse.osgi.util.NLS; + +/** + * Validates a table name: it must be a valid XML name, not already used, and not the name reserved for + * the root element. + * + * @since 1.0.0 + */ +public final class TableNameValidator implements IInputValidator +{ + private static final String RESERVED_ROOT_NAME = "dataset"; + + private final Function tableKeyOf; + + private final Collection existingNames; + + /** + * Creates a validator. + * + * @param tableKeyOf Computes the key a table name would have, accounting for the document's + * case-sensitivity rule; see {@code DatasetDocument.tableKeyOf}. + * @param existingNames The names already in use; the entered name must not match any of them, by key. + * The validator keeps its own copy of them. + */ + public TableNameValidator(final Function tableKeyOf, final Collection existingNames) + { + this.tableKeyOf = tableKeyOf; + this.existingNames = List.copyOf(existingNames); + } + + @Override + public String isValid(final String newText) + { + if (newText == null || newText.isEmpty()) + { + return Messages.NameValidator_empty; + } + if (!XmlNames.isValidName(newText)) + { + return NLS.bind(Messages.NameValidator_invalid, newText); + } + final String newKey = tableKeyOf.apply(newText); + if (newKey.equals(tableKeyOf.apply(RESERVED_ROOT_NAME))) + { + return NLS.bind(Messages.TableNameValidator_reserved, newText); + } + for (final String existingName : existingNames) + { + if (tableKeyOf.apply(existingName).equals(newKey)) + { + return NLS.bind(Messages.NameValidator_used, newText); + } + } + return null; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/ActiveGrid.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/ActiveGrid.java new file mode 100644 index 00000000..0ea5e4f0 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/ActiveGrid.java @@ -0,0 +1,135 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import java.util.List; +import java.util.function.Supplier; + +import org.dbunit.eclipse.dataset.core.model.CellAddress; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGrid; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.nebula.widgets.nattable.NatTable; +import org.eclipse.nebula.widgets.nattable.edit.editor.ICellEditor; +import org.eclipse.swt.custom.CTabFolder; +import org.eclipse.swt.custom.CTabItem; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Text; + +/** + * What the Tables page asks of the grid of the selected tab: its selection, the region and cells to select, + * whether one of its cells is being edited and the text of that editor, and the cell address of its anchor. + * Without a selected tab the answers are empty and the requests do nothing. All of it belongs to the tab + * folder, so it may be used on the UI thread only. + */ +final class ActiveGrid +{ + private final CTabFolder tabFolder; + + private final Supplier activeGrid; + + /** + * Creates the view on the selected tab's grid. + * + * @param tabFolder The folder whose selected tab holds the grid. + * @param activeGrid Returns the grid of the selected tab, or null when no tab is selected. + */ + ActiveGrid(final CTabFolder tabFolder, final Supplier activeGrid) + { + this.tabFolder = tabFolder; + this.activeGrid = activeGrid; + } + + boolean hasActiveCellEditor() + { + final CTabItem selected = tabFolder.getSelection(); + return selected != null && selected.getControl() instanceof NatTable + && ((NatTable) selected.getControl()).getActiveCellEditor() != null; + } + + GridSelection getSelection() + { + final DatasetGrid grid = activeGrid.get(); + return grid != null ? grid.getSelection() : GridSelection.NONE; + } + + void selectRegion(final int firstColumnIndex, final int firstRowIndex, final int columnCount, + final int rowCount) + { + final DatasetGrid grid = activeGrid.get(); + if (grid != null) + { + grid.selectRegion(firstColumnIndex, firstRowIndex, columnCount, rowCount); + } + } + + Text getActiveCellEditorText() + { + final CTabItem selected = tabFolder.getSelection(); + if (selected == null || !(selected.getControl() instanceof NatTable)) + { + return null; + } + final ICellEditor cellEditor = ((NatTable) selected.getControl()).getActiveCellEditor(); + if (cellEditor == null) + { + return null; + } + final Control editorControl = cellEditor.getEditorControl(); + return editorControl instanceof final Text text ? text : null; + } + + List getSelectedCellPositions() + { + final DatasetGrid grid = activeGrid.get(); + return grid != null ? grid.getSelectedCellPositions() : List.of(); + } + + void selectAll() + { + final DatasetGrid grid = activeGrid.get(); + if (grid != null) + { + grid.selectAll(); + } + } + + void editCellInDialog() + { + final DatasetGrid grid = activeGrid.get(); + if (grid != null) + { + grid.editCellInDialog(); + } + } + + CellAddress currentCellAddress() + { + final GridSelection selection = getSelection(); + if (selection.tableKey() == null || selection.anchorRowIndex() < 0 + || selection.anchorColumnIndex() < 0) + { + return null; + } + return new CellAddress(selection.tableKey(), selection.anchorRowIndex(), + selection.anchorColumnIndex()); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/DatasetEditorContributor.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/DatasetEditorContributor.java new file mode 100644 index 00000000..d1415a3f --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/DatasetEditorContributor.java @@ -0,0 +1,151 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import java.util.Set; + +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.action.IStatusLineManager; +import org.eclipse.ui.IActionBars; +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.actions.ActionFactory; +import org.eclipse.ui.part.MultiPageEditorActionBarContributor; +import org.eclipse.ui.texteditor.ITextEditor; +import org.eclipse.ui.texteditor.ITextEditorActionConstants; + +/** + * Routes the global Edit, Revert, and Print menu actions to whichever page of {@link FlatXmlDatasetEditor} + * is active, and shows the status fields of the text editor while its Source page is. + * + * @since 1.0.0 + */ +public final class DatasetEditorContributor extends MultiPageEditorActionBarContributor +{ + private static final String[] GLOBAL_ACTION_IDS = { ActionFactory.UNDO.getId(), + ActionFactory.REDO.getId(), ActionFactory.CUT.getId(), ActionFactory.COPY.getId(), + ActionFactory.PASTE.getId(), ActionFactory.DELETE.getId(), ActionFactory.SELECT_ALL.getId(), + ActionFactory.FIND.getId(), ActionFactory.PRINT.getId(), ActionFactory.REVERT.getId() }; + + private static final String[] TEXT_EDITOR_ACTION_IDS = { ITextEditorActionConstants.UNDO, + ITextEditorActionConstants.REDO, ITextEditorActionConstants.CUT, + ITextEditorActionConstants.COPY, ITextEditorActionConstants.PASTE, + ITextEditorActionConstants.DELETE, ITextEditorActionConstants.SELECT_ALL, + ITextEditorActionConstants.FIND, ITextEditorActionConstants.PRINT, + ITextEditorActionConstants.REVERT }; + + /** + * The global actions that stay the text editor's on the Tables page, because they act on the document + * that both pages share. + */ + private static final Set SHARED_DOCUMENT_ACTION_IDS = Set.of(ActionFactory.REVERT.getId()); + + private final SourceStatusFields statusFields = new SourceStatusFields(); + + private FlatXmlDatasetEditor multiPageEditor; + + /** + * Returns the status fields of the Source page, for a test to read what they show. + * + * @return The status fields that this contributor shares among the dataset editors. + */ + SourceStatusFields getStatusFields() + { + return statusFields; + } + + /** + * Remembers the dataset editor that became active, then installs the global action handlers of its + * active page. + * + * @param part The editor that became active. + */ + @Override + public void setActiveEditor(final IEditorPart part) + { + multiPageEditor = part instanceof final FlatXmlDatasetEditor datasetEditor ? datasetEditor : null; + super.setActiveEditor(part); + } + + /** + * Adds the status fields of the Source page to the status line. They stay hidden until the Source page + * of a dataset editor is active. + * + * @param statusLineManager The manager of the status line of the dataset editors. + */ + @Override + public void contributeToStatusLine(final IStatusLineManager statusLineManager) + { + super.contributeToStatusLine(statusLineManager); + statusFields.contributeTo(statusLineManager); + } + + /** + * Installs the global action handlers of the active dataset editor's active page: the text editor's + * actions for the Source page, and the Tables page's actions for the Tables page, which has no Find or + * Print action, and which reverts the document with the text editor's Revert action. The status fields + * of the text editor are shown for the Source page, and hidden for the Tables page. The page comes from + * the active dataset editor rather than from the argument, because every open dataset editor reports + * its page changes to this contributor, which the editors share. + * + * @param activeEditor The nested editor of the page that became active, or null for the Tables page. + */ + @Override + public void setActivePage(final IEditorPart activeEditor) + { + if (multiPageEditor == null || multiPageEditor.getTablesPage() == null) + { + return; + } + final boolean sourcePageActive = multiPageEditor.isSourcePageActive(); + installGlobalActionHandlers(multiPageEditor, sourcePageActive); + if (sourcePageActive) + { + statusFields.showFor(multiPageEditor.getSourceEditor()); + } + else + { + statusFields.hide(); + } + getActionBars().updateActionBars(); + } + + private void installGlobalActionHandlers(final FlatXmlDatasetEditor datasetEditor, + final boolean sourcePageActive) + { + final IActionBars actionBars = getActionBars(); + final ITextEditor sourceEditor = datasetEditor.getSourceEditor(); + final TablesPage tablesPage = datasetEditor.getTablesPage(); + for (int index = 0; index < GLOBAL_ACTION_IDS.length; index++) + { + final String globalActionId = GLOBAL_ACTION_IDS[index]; + final IAction action; + if (sourcePageActive || SHARED_DOCUMENT_ACTION_IDS.contains(globalActionId)) + { + action = sourceEditor.getAction(TEXT_EDITOR_ACTION_IDS[index]); + } + else + { + action = tablesPage.getGlobalActionHandler(globalActionId); + } + actionBars.setGlobalActionHandler(globalActionId, action); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/EditorInputDtdSource.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/EditorInputDtdSource.java new file mode 100644 index 00000000..c80c41f5 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/EditorInputDtdSource.java @@ -0,0 +1,283 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import org.dbunit.eclipse.dataset.core.dtd.DtdSource; +import org.eclipse.core.filesystem.IFileStore; +import org.eclipse.core.resources.IContainer; +import org.eclipse.core.resources.IFile; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.ILog; +import org.eclipse.core.runtime.Platform; +import org.eclipse.core.runtime.content.IContentDescription; +import org.eclipse.core.runtime.content.IContentType; +import org.eclipse.ui.IEditorInput; +import org.eclipse.ui.ide.ResourceUtil; + +/** + * Loads an external DTD relative to an editor input, without ever accessing the network. It refuses a + * system ID that reaches a network share, such as {@code //host/share/dataset.dtd}, which Windows opens by + * connecting to the host. A DTD that cannot be read is logged with the reason, so that the "DTD not loaded" + * warning has an explanation; a DTD that stays unreadable is logged once, not each time the editor loads it + * again. + * + * @since 1.0.0 + */ +final class EditorInputDtdSource implements DtdSource +{ + private static final ILog LOG = ILog.of(EditorInputDtdSource.class); + + private static final String XML_CONTENT_TYPE = "org.eclipse.core.runtime.xml"; + + /** + * The start of the root of a Windows path on a network share, such as {@code \\host\share\}. + */ + private static final String UNC_ROOT_PREFIX = "\\\\"; + + private static final boolean BACKSLASH_IS_SEPARATOR = File.separatorChar == '\\'; + + private final IEditorInput input; + + /** + * The reason last logged for each system ID whose DTD could not be read, until a load of it succeeds. + */ + private final Map reportedFailures = new HashMap<>(); + + EditorInputDtdSource(final IEditorInput input) + { + this.input = input; + } + + @Override + public Optional load(final String publicId, final String systemId) + { + if (systemId == null || systemId.isEmpty()) + { + return Optional.empty(); + } + try + { + final Optional loaded = loadResolved(systemId); + if (loaded.isPresent()) + { + reportedFailures.remove(systemId); + } + return loaded; + } + catch (final RuntimeException e) + { + reportFailure(systemId, e); + return Optional.empty(); + } + } + + private Optional loadResolved(final String systemId) + { + if (isWindowsPath(systemId)) + { + return readLocalFile(systemId, Paths.get(systemId)); + } + final URI asUri = parseUri(systemId); + if (asUri != null && asUri.isAbsolute()) + { + if (!"file".equalsIgnoreCase(asUri.getScheme())) + { + return Optional.empty(); + } + return readLocalFile(systemId, Paths.get(asUri)); + } + return loadRelative(systemId); + } + + private Optional loadRelative(final String systemId) + { + final IFile datasetFile = ResourceUtil.getFile(input); + if (datasetFile != null) + { + final URI location = datasetFile.getLocationURI(); + if (location != null) + { + return loadUriRelative(location, systemId); + } + return loadWorkspaceRelative(datasetFile, systemId); + } + final IFileStore fileStore = input.getAdapter(IFileStore.class); + if (fileStore != null) + { + return loadUriRelative(fileStore.toURI(), systemId); + } + return Optional.empty(); + } + + private Optional loadWorkspaceRelative(final IFile datasetFile, final String systemId) + { + final IContainer folder = datasetFile.getParent(); + if (folder == null) + { + return Optional.empty(); + } + final IFile dtdFile = folder.getFile(new org.eclipse.core.runtime.Path(systemId)); + if (!dtdFile.exists()) + { + return Optional.empty(); + } + try (InputStream stream = dtdFile.getContents()) + { + final Charset charset = Charset.forName(dtdFile.getCharset()); + return Optional.of(new String(stream.readAllBytes(), charset)); + } + catch (final CoreException | IOException e) + { + reportFailure(systemId, e); + return Optional.empty(); + } + } + + private Optional loadUriRelative(final URI datasetUri, final String systemId) + { + final URI resolved; + try + { + resolved = datasetUri.resolve(new SystemIdentifier(systemId, BACKSLASH_IS_SEPARATOR).toUri()); + } + catch (final URISyntaxException | IllegalArgumentException e) + { + reportFailure(systemId, e); + return Optional.empty(); + } + if (!"file".equalsIgnoreCase(resolved.getScheme())) + { + return Optional.empty(); + } + return readLocalFile(systemId, Paths.get(resolved)); + } + + private Optional readLocalFile(final String systemId, final Path path) + { + requireNotOnNetworkShare(path); + try + { + final byte[] content = Files.readAllBytes(path); + final Charset charset = detectCharset(content); + return Optional.of(charset.newDecoder().decode(ByteBuffer.wrap(content)).toString()); + } + catch (final IOException e) + { + reportFailure(systemId, e); + return Optional.empty(); + } + } + + /** + * Refuses a path on a network share. Windows opens a UNC path by connecting to its host and sending the + * user's credentials, so a DOCTYPE with a system ID that names a host, such as + * {@code //host/share/dataset.dtd} or {@code file://host/share/dataset.dtd}, would make the editor + * contact that host each time it loads the DTD. + */ + private static void requireNotOnNetworkShare(final Path path) + { + final Path root = path.getRoot(); + if (root == null) + { + return; + } + final String rootText = root.toString(); + if (rootText.startsWith(UNC_ROOT_PREFIX)) + { + throw new IllegalArgumentException( + "The editor never reads a DTD from the network, and " + path + " is on a network share."); + } + } + + /** + * Logs why the DTD could not be read, unless the same reason was already logged for it since the last + * time it was read successfully. + */ + private void reportFailure(final String systemId, final Exception cause) + { + final String reason = cause.toString(); + final String previousReason = reportedFailures.put(systemId, reason); + if (reason.equals(previousReason)) + { + return; + } + LOG.warn("The DTD \"" + systemId + "\" could not be read.", cause); + } + + /** + * Returns the charset Eclipse's XML content-type describer detects from a byte-order mark or a text + * declaration in content, the same way {@link IFile#getCharset()} detects it for a workspace file, + * falling back to UTF-8 when nothing is detected. + */ + private static Charset detectCharset(final byte[] content) + { + final IContentType xmlContentType = + Platform.getContentTypeManager().getContentType(XML_CONTENT_TYPE); + if (xmlContentType == null) + { + return StandardCharsets.UTF_8; + } + try (InputStream stream = new ByteArrayInputStream(content)) + { + final IContentDescription description = + xmlContentType.getDescriptionFor(stream, IContentDescription.ALL); + final String charsetName = description == null ? null : description.getCharset(); + return charsetName == null ? StandardCharsets.UTF_8 : Charset.forName(charsetName); + } + catch (final IOException | IllegalArgumentException e) + { + return StandardCharsets.UTF_8; + } + } + + private static URI parseUri(final String value) + { + try + { + return new URI(value); + } + catch (final URISyntaxException e) + { + return null; + } + } + + private static boolean isWindowsPath(final String value) + { + return value.length() >= 2 && Character.isLetter(value.charAt(0)) && value.charAt(1) == ':'; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/EncodingResolver.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/EncodingResolver.java new file mode 100644 index 00000000..703a4912 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/EncodingResolver.java @@ -0,0 +1,72 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import java.nio.charset.Charset; +import java.nio.charset.IllegalCharsetNameException; +import java.nio.charset.StandardCharsets; +import java.nio.charset.UnsupportedCharsetException; + +import org.eclipse.core.runtime.ILog; + +/** + * Turns the encoding name that the Source page reports into a charset, using UTF-8 for a name that Java does + * not support. An unsupported name is logged once, not each time the editor asks for the charset, which it + * does for every edit that needs one. + * + * @since 1.0.0 + */ +final class EncodingResolver +{ + private static final ILog LOG = ILog.of(EncodingResolver.class); + + private String reportedEncoding; + + /** + * Returns the charset for an encoding name. + * + * @param encoding The encoding name. + * @return The named charset, or UTF-8 when Java does not support the name. + */ + Charset charsetOf(final String encoding) + { + try + { + return Charset.forName(encoding); + } + catch (final IllegalCharsetNameException | UnsupportedCharsetException e) + { + report(encoding, e); + return StandardCharsets.UTF_8; + } + } + + private void report(final String encoding, final IllegalArgumentException cause) + { + if (encoding.equals(reportedEncoding)) + { + return; + } + reportedEncoding = encoding; + LOG.warn("The encoding \"" + encoding + "\" is not supported, so the dataset editor uses UTF-8 instead.", + cause); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/ErrorBanner.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/ErrorBanner.java new file mode 100644 index 00000000..fb9913ae --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/ErrorBanner.java @@ -0,0 +1,136 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import org.dbunit.eclipse.dataset.core.model.DatasetProblem; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.SelectionListener; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Link; +import org.eclipse.ui.ISharedImages; +import org.eclipse.ui.PlatformUI; + +/** + * A banner shown at the top of {@link TablesPage} for a blocking problem or a read-only dataset, hidden + * otherwise. + * + * @since 1.0.0 + */ +final class ErrorBanner +{ + private final Composite control; + + private final Label image; + + private final Label message; + + private final Link showInSourceLink; + + private DatasetProblem shownProblem; + + ErrorBanner(final Composite parent, final FlatXmlDatasetEditor editor) + { + control = new Composite(parent, SWT.NONE); + control.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false)); + final GridLayout layout = new GridLayout(3, false); + control.setLayout(layout); + + image = new Label(control, SWT.NONE); + message = new Label(control, SWT.NONE); + message.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false)); + showInSourceLink = new Link(control, SWT.NONE); + showInSourceLink.setText(Messages.ErrorBanner_showInSource); + showInSourceLink.addSelectionListener(SelectionListener.widgetSelectedAdapter(event -> + { + if (shownProblem != null) + { + editor.showOnSourcePage(shownProblem.offset(), shownProblem.length()); + } + })); + + hide(); + } + + Control getControl() + { + return control; + } + + void showBlocking(final DatasetProblem problem) + { + shownProblem = problem; + image.setImage(sharedImage(ISharedImages.IMG_OBJS_ERROR_TSK)); + message.setText(problem.message()); + showInSourceLink.setVisible(true); + reveal(); + } + + void showReadOnly() + { + shownProblem = null; + image.setImage(sharedImage(ISharedImages.IMG_OBJS_WARN_TSK)); + message.setText(Messages.ErrorBanner_readOnly); + showInSourceLink.setVisible(false); + reveal(); + } + + void hide() + { + shownProblem = null; + if (!isExcluded()) + { + setExcluded(true); + control.getParent().layout(); + } + } + + private void reveal() + { + control.layout(); + if (isExcluded()) + { + setExcluded(false); + control.getParent().layout(); + } + } + + private boolean isExcluded() + { + return ((GridData) control.getLayoutData()).exclude; + } + + private void setExcluded(final boolean excluded) + { + control.setVisible(!excluded); + ((GridData) control.getLayoutData()).exclude = excluded; + } + + private static Image sharedImage(final String key) + { + return PlatformUI.getWorkbench().getSharedImages().getImage(key); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/FlatXmlDatasetEditor.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/FlatXmlDatasetEditor.java new file mode 100644 index 00000000..5e64382c --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/FlatXmlDatasetEditor.java @@ -0,0 +1,473 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Objects; +import java.util.Optional; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.core.model.DatasetModel; +import org.dbunit.eclipse.dataset.ui.DatasetUiPlugin; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.preferences.PreferenceKeys; +import org.eclipse.core.runtime.ILog; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.IFindReplaceTarget; +import org.eclipse.jface.util.IPropertyChangeListener; +import org.eclipse.swt.custom.CTabFolder; +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.IEditorInput; +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.IEditorSite; +import org.eclipse.ui.IPartListener2; +import org.eclipse.ui.IPropertyListener; +import org.eclipse.ui.IWindowListener; +import org.eclipse.ui.IWorkbenchPage; +import org.eclipse.ui.IWorkbenchPartReference; +import org.eclipse.ui.IWorkbenchWindow; +import org.eclipse.ui.PartInitException; +import org.eclipse.ui.editors.text.IEncodingSupport; +import org.eclipse.ui.ide.IGotoMarker; +import org.eclipse.ui.part.MultiPageEditorPart; +import org.eclipse.ui.texteditor.AbstractTextEditor; +import org.eclipse.ui.texteditor.ITextEditor; + +/** + * The dbUnit Dataset Editor: a Tables page and a Source page over the same underlying document. + * + * @since 1.0.0 + */ +public final class FlatXmlDatasetEditor extends MultiPageEditorPart +{ + private static final ILog LOG = ILog.of(FlatXmlDatasetEditor.class); + + /** + * The identifier this editor is registered under in {@code plugin.xml}. + */ + public static final String ID = "org.dbunit.eclipse.dataset.ui.flatXmlDatasetEditor"; + + private static final int TABLES_PAGE_INDEX = 0; + + private static final int SOURCE_PAGE_INDEX = 1; + + private final EncodingResolver encodingResolver = new EncodingResolver(); + + private final IPropertyListener sourceInputListener = (source, propertyId) -> + { + if (propertyId == IEditorPart.PROP_INPUT) + { + handleInputChanged(); + } + }; + + private final IPartListener2 partListener = new IPartListener2() + { + @Override + public void partActivated(final IWorkbenchPartReference partRef) + { + if (FlatXmlDatasetEditor.this.equals(partRef.getPart(false))) + { + refreshExternalState(); + } + } + }; + + private final IWindowListener windowListener = new IWindowListener() + { + @Override + public void windowActivated(final IWorkbenchWindow window) + { + handleWindowActivated(window); + } + + @Override + public void windowDeactivated(final IWorkbenchWindow window) + { + // Nothing to do: the editor only reacts to a window being activated. + } + + @Override + public void windowClosed(final IWorkbenchWindow window) + { + // Nothing to do: the editor only reacts to a window being activated. + } + + @Override + public void windowOpened(final IWorkbenchWindow window) + { + // Nothing to do: the editor only reacts to a window being activated. + } + }; + + private final IPreferenceStore preferenceStore = DatasetUiPlugin.getDefault().getPreferenceStore(); + + private final IPropertyChangeListener preferenceListener = event -> + { + final String property = event.getProperty(); + // Preferences can change on any thread, for example when a setup tool applies them in a job. + if (Display.getCurrent() == null) + { + Display.getDefault().asyncExec(() -> preferenceChanged(property)); + } + else + { + preferenceChanged(property); + } + }; + + private FlatXmlSourceEditor sourceEditor; + + private SaveEncoding saveEncoding; + + private TablesPage tablesPage; + + private FlatXmlDatasetDocument datasetDocument; + + private int previousPageIndex = -1; + + @Override + public void init(final IEditorSite site, final IEditorInput input) throws PartInitException + { + super.init(site, input); + setPartName(input.getName()); + } + + @Override + protected void createPages() + { + sourceEditor = new FlatXmlSourceEditor(this::cancelActiveCellEditor, this::closeEditor, + this::showSourcePage); + final int sourcePageIndex; + try + { + sourcePageIndex = addPage(sourceEditor, getEditorInput()); + } + catch (final PartInitException e) + { + throw new IllegalStateException(e.getStatus().getMessage(), e); + } + setPageText(sourcePageIndex, Messages.Editor_sourcePageText); + + final IDocument document = + sourceEditor.getDocumentProvider().getDocument(sourceEditor.getEditorInput()); + saveEncoding = new SaveEncoding(sourceEditor::getEditorInput, + () -> sourceEditor.getDocumentProvider().getDocument(sourceEditor.getEditorInput())); + datasetDocument = new FlatXmlDatasetDocument(document, + new EditorInputDtdSource(getEditorInput()), PreferenceKeys.readOptions(), + this::currentCharset, LOG::log); + datasetDocument.refresh(); + + tablesPage = new TablesPage(getContainer(), this, datasetDocument); + addPage(TABLES_PAGE_INDEX, tablesPage.getControl()); + setPageText(TABLES_PAGE_INDEX, Messages.Editor_tablesPageText); + + final DatasetModel model = datasetDocument.getModel(); + final boolean editable = model.isEditable() || model.isBlank(); + setActivePage(editable ? TABLES_PAGE_INDEX : SOURCE_PAGE_INDEX); + previousPageIndex = getActivePage(); + + sourceEditor.addPropertyListener(sourceInputListener); + getSite().getPage().addPartListener(partListener); + getSite().getWorkbenchWindow().getWorkbench().addWindowListener(windowListener); + preferenceStore.addPropertyChangeListener(preferenceListener); + } + + @Override + protected void pageChange(final int newPageIndex) + { + if (previousPageIndex == TABLES_PAGE_INDEX) + { + if (!tablesPage.commitActiveCellEditor()) + { + keepTablesPage(); + return; + } + tablesPage.deactivate(); + } + super.pageChange(newPageIndex); + if (newPageIndex == TABLES_PAGE_INDEX) + { + datasetDocument.refresh(); + tablesPage.activate(); + } + previousPageIndex = newPageIndex; + } + + @Override + public void doSave(final IProgressMonitor monitor) + { + if (!commitActiveCellEditor()) + { + monitor.setCanceled(true); + return; + } + sourceEditor.doSave(monitor); + } + + @Override + public void doSaveAs() + { + if (commitActiveCellEditor()) + { + sourceEditor.doSaveAs(); + } + } + + @Override + public boolean isSaveAsAllowed() + { + return true; + } + + FlatXmlSourceEditor getSourceEditor() + { + return sourceEditor; + } + + FlatXmlDatasetDocument getDatasetDocument() + { + return datasetDocument; + } + + TablesPage getTablesPage() + { + return tablesPage; + } + + String getPageTitle(final int pageIndex) + { + return getPageText(pageIndex); + } + + boolean isSourcePageActive() + { + return getActivePage() == SOURCE_PAGE_INDEX; + } + + void showOnSourcePage(final int offset, final int length) + { + sourceEditor.selectAndReveal(offset, length); + } + + @Override + public T getAdapter(final Class adapterClass) + { + if (adapterClass == IGotoMarker.class) + { + final IGotoMarker gotoMarker = marker -> + { + setActivePage(SOURCE_PAGE_INDEX); + sourceEditor.getAdapter(IGotoMarker.class).gotoMarker(marker); + }; + return adapterClass.cast(gotoMarker); + } + if (adapterClass == ITextEditor.class) + { + return adapterClass.cast(sourceEditor); + } + if (adapterClass == AbstractTextEditor.class) + { + // A text editor asks whether its file changed on disk only after the part that holds it was + // activated, which it counts only when that part answers with the editor. The inherited answer + // is the editor of the page that is shown, so the Source editor would never ask on the Tables + // page. The zoom commands of the workbench ask the same question, so they are enabled there too. + return adapterClass.cast(sourceEditor); + } + if (adapterClass == IFindReplaceTarget.class) + { + return getActivePage() == SOURCE_PAGE_INDEX ? sourceEditor.getAdapter(adapterClass) : null; + } + return super.getAdapter(adapterClass); + } + + @Override + public void dispose() + { + removeSourceEditorListeners(); + removeWorkbenchListeners(); + preferenceStore.removePropertyChangeListener(preferenceListener); + if (datasetDocument != null) + { + datasetDocument.dispose(); + } + if (tablesPage != null) + { + tablesPage.dispose(); + } + super.dispose(); + } + + private void removeSourceEditorListeners() + { + if (sourceEditor != null) + { + sourceEditor.removePropertyListener(sourceInputListener); + } + } + + private void removeWorkbenchListeners() + { + if (getSite() != null && getSite().getPage() != null) + { + getSite().getPage().removePartListener(partListener); + } + if (getSite() != null && getSite().getWorkbenchWindow() != null) + { + getSite().getWorkbenchWindow().getWorkbench().removeWindowListener(windowListener); + } + } + + private void handleInputChanged() + { + final IEditorInput newInput = sourceEditor.getEditorInput(); + setInputWithNotify(newInput); + setPartName(newInput.getName()); + final IDocument document = sourceEditor.getDocumentProvider().getDocument(newInput); + datasetDocument.rebind(document, new EditorInputDtdSource(newInput)); + tablesPage.inputChanged(); + } + + /** + * Puts the selection of the page tabs back on the Tables page, for a page change that is refused. The + * change itself is not run again, because the Tables page was never left. + */ + private void keepTablesPage() + { + if (getContainer() instanceof final CTabFolder tabFolder) + { + tabFolder.setSelection(TABLES_PAGE_INDEX); + } + } + + private boolean commitActiveCellEditor() + { + if (tablesPage == null) + { + return true; + } + return tablesPage.commitActiveCellEditor(); + } + + /** + * Brings the Source page forward, unless it is shown already or the pages are not all created yet, as + * when the Source editor selects text while it is set up. + */ + void showSourcePage() + { + final boolean pagesExist = getPageCount() > SOURCE_PAGE_INDEX; + if (pagesExist && getActivePage() != SOURCE_PAGE_INDEX) + { + setActivePage(SOURCE_PAGE_INDEX); + } + } + + private void cancelActiveCellEditor() + { + if (tablesPage != null) + { + tablesPage.cancelActiveCellEditor(); + } + } + + private void closeEditor(final boolean save) + { + Display.getDefault().asyncExec(() -> + { + // An earlier request to close the editor may have closed it already, which takes its page away. + final IWorkbenchPage page = getSite().getPage(); + if (page != null) + { + page.closeEditor(FlatXmlDatasetEditor.this, save); + } + }); + } + + /** + * Checks the files that this editor shows when its own window is activated and it is the active part of + * its page. The workbench tells every editor of every window about each window that is activated, and + * the editor of another window has nothing to check then: a DTD that it read again or a prompt that it + * showed would belong to the window that the user left. + * + * @param window The window that was activated. + */ + void handleWindowActivated(final IWorkbenchWindow window) + { + final boolean ownWindow = Objects.equals(getSite().getWorkbenchWindow(), window); + if (ownWindow && FlatXmlDatasetEditor.this.equals(getSite().getPage().getActivePart())) + { + refreshExternalState(); + } + } + + private void refreshExternalState() + { + sourceEditor.checkExternalModification(); + datasetDocument.reloadDtd(); + tablesPage.refreshExternalState(); + } + + private void preferenceChanged(final String property) + { + if (tablesPage.getControl().isDisposed()) + { + return; + } + if (PreferenceKeys.NULL_DISPLAY_TEXT.equals(property)) + { + tablesPage.repaintGrids(); + } + else if (PreferenceKeys.ASSUME_COLUMN_SENSING.equals(property) + || PreferenceKeys.CASE_SENSITIVE_TABLE_NAMES.equals(property)) + { + datasetDocument.setOptions(PreferenceKeys.readOptions()); + } + } + + Charset currentCharset() + { + final Optional encodingOfText = saveEncoding.decidedByText(); + if (encodingOfText.isPresent()) + { + return encodingResolver.charsetOf(encodingOfText.get()); + } + return charsetOfBuffer(); + } + + private Charset charsetOfBuffer() + { + final IEncodingSupport encodingSupport = sourceEditor.getAdapter(IEncodingSupport.class); + if (encodingSupport == null) + { + return StandardCharsets.UTF_8; + } + final String encoding = encodingSupport.getEncoding() != null ? encodingSupport.getEncoding() + : encodingSupport.getDefaultEncoding(); + if (encoding == null) + { + return StandardCharsets.UTF_8; + } + return encodingResolver.charsetOf(encoding); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/FlatXmlSourceEditor.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/FlatXmlSourceEditor.java new file mode 100644 index 00000000..5661a75c --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/FlatXmlSourceEditor.java @@ -0,0 +1,153 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import java.util.function.Consumer; + +import org.dbunit.eclipse.dataset.ui.source.XmlDocumentSetupParticipant; +import org.dbunit.eclipse.dataset.ui.source.XmlSourceViewerConfiguration; +import org.dbunit.eclipse.dataset.ui.source.XmlTokenColors; +import org.eclipse.jface.text.source.ISourceViewer; +import org.eclipse.swt.custom.StyledText; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.editors.text.ForwardingDocumentProvider; +import org.eclipse.ui.editors.text.TextEditor; +import org.eclipse.ui.editors.text.TextFileDocumentProvider; +import org.eclipse.ui.themes.IThemeManager; + +/** + * The Source page of {@link FlatXmlDatasetEditor}: a text editor over the same document that colors the + * XML syntax, with the additions the multi-page editor needs: to detect changes made outside the + * workbench, to let the Tables page drop a cell edit in progress before the document is reverted, to + * close the whole dataset editor when it is asked to close itself, and to bring its page forward when + * text in it is selected. + * + * @since 1.0.0 + */ +public class FlatXmlSourceEditor extends TextEditor +{ + private final XmlTokenColors tokenColors; + + private final Runnable beforeRevert; + + private final Consumer closeDatasetEditor; + + private final Runnable showPage; + + /** + * Creates the Source page editor, which colors the XML syntax with the current theme's colors. + * + * @param beforeRevert Runs before the editor reverts its document to the text of the last save, so + * that a cell edit in progress on the Tables page does not write its value + * into the reverted document. + * @param closeDatasetEditor Closes the dataset editor that holds this page, saving its changes first + * when it is given true. + * @param showPage Brings this page forward in the dataset editor, so that text that is + * selected from outside, such as a search result, is in view. + */ + public FlatXmlSourceEditor(final Runnable beforeRevert, final Consumer closeDatasetEditor, + final Runnable showPage) + { + this.beforeRevert = beforeRevert; + this.closeDatasetEditor = closeDatasetEditor; + this.showPage = showPage; + final IThemeManager themeManager = PlatformUI.getWorkbench().getThemeManager(); + tokenColors = new XmlTokenColors(themeManager, this::redrawSyntaxColors); + setSourceViewerConfiguration(new XmlSourceViewerConfiguration(getPreferenceStore(), tokenColors)); + setDocumentProvider(new ForwardingDocumentProvider(XmlDocumentSetupParticipant.PARTITIONING, + new XmlDocumentSetupParticipant(), new TextFileDocumentProvider())); + } + + /** + * Checks whether the editor input changed or was deleted outside the workbench, prompting to reload + * or save as needed. The multi-page editor calls this when it or its window is activated, so that the + * check does not depend on how the inherited editor follows the activation of the part that holds it. + */ + public void checkExternalModification() + { + safelySanityCheckState(getEditorInput()); + } + + /** + * Brings this page forward, then selects and reveals the range. Code that selects text in a text editor, + * such as a search result or a link in the console, reaches this editor through the dataset editor, and + * would otherwise select text on a page that is not shown. + * + * @param start The offset of the range. + * @param length The length of the range. + */ + @Override + public void selectAndReveal(final int start, final int length) + { + showPage.run(); + super.selectAndReveal(start, length); + } + + /** + * Runs the action that was given to the constructor, then reverts the document to the text of the last + * save. + */ + @Override + protected void performRevert() + { + beforeRevert.run(); + super.performRevert(); + } + + /** + * Closes the dataset editor that holds this page, because the workbench page knows only the dataset + * editor, so it would ignore a request to close this page alone. Sanity checking is turned off first, as + * the inherited implementation does, so that a change outside the workbench does not prompt again for an + * editor that is about to close. + * + * @param save True to save the changes first, false to discard them. + */ + @Override + public void close(final boolean save) + { + enableSanityChecking(false); + closeDatasetEditor.accept(save); + } + + /** + * Stops following theme changes and disposes the editor. + */ + @Override + public void dispose() + { + tokenColors.dispose(); + super.dispose(); + } + + private void redrawSyntaxColors() + { + final ISourceViewer sourceViewer = getSourceViewer(); + if (sourceViewer == null) + { + return; + } + final StyledText textWidget = sourceViewer.getTextWidget(); + if (textWidget != null && !textWidget.isDisposed()) + { + sourceViewer.invalidateTextPresentation(); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/PageSelectionSync.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/PageSelectionSync.java new file mode 100644 index 00000000..bb70bae5 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/PageSelectionSync.java @@ -0,0 +1,100 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.function.Supplier; + +import org.dbunit.eclipse.dataset.core.edit.TextDatasetDocument; +import org.dbunit.eclipse.dataset.core.model.CellAddress; +import org.eclipse.jface.text.IRegion; + +/** + * Keeps the Tables and Source pages' selections in sync as the active page switches. + * + * @since 1.0.0 + */ +final class PageSelectionSync +{ + private final Supplier sourceSelection; + + private final BiConsumer selectAndRevealOnSource; + + private final TextDatasetDocument datasetDocument; + + private IRegion lastSourceRegion; + + /** + * Creates a page selection sync. + * + * @param sourceSelection Returns the Source page's current selection, as a range. + * @param selectAndRevealOnSource Selects and reveals a range (offset, length) on the Source page. + * @param datasetDocument The document to locate cells in and find cells by offset in. + */ + PageSelectionSync(final Supplier sourceSelection, + final BiConsumer selectAndRevealOnSource, + final TextDatasetDocument datasetDocument) + { + this.sourceSelection = sourceSelection; + this.selectAndRevealOnSource = selectAndRevealOnSource; + this.datasetDocument = datasetDocument; + } + + /** + * Called when leaving the Tables page: selects and reveals the cell's range on the Source page. + * + * @param selectedCell The address of the selected cell, or null when there is none. + */ + void onDeactivate(final CellAddress selectedCell) + { + lastSourceRegion = null; + if (selectedCell == null) + { + return; + } + final Optional region = datasetDocument.locate(selectedCell); + if (region.isEmpty()) + { + return; + } + lastSourceRegion = region.get(); + selectAndRevealOnSource.accept(lastSourceRegion.getOffset(), lastSourceRegion.getLength()); + } + + /** + * Called when entering the Tables page: decides whether the grid selection should change. + * + * @return The cell to select, when the Source page's selection moved since {@link #onDeactivate}; an + * empty result when it is still the range that was set then, so the grid selection must be + * kept as is. + */ + Optional onActivate() + { + final IRegion selection = sourceSelection.get(); + if (lastSourceRegion != null && selection.getOffset() == lastSourceRegion.getOffset() + && selection.getLength() == lastSourceRegion.getLength()) + { + return Optional.empty(); + } + return datasetDocument.cellAt(selection.getOffset()); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/PageServices.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/PageServices.java new file mode 100644 index 00000000..05d5d773 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/PageServices.java @@ -0,0 +1,184 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import java.util.function.BooleanSupplier; +import java.util.function.Supplier; + +import org.dbunit.eclipse.dataset.core.edit.DatasetEditException; +import org.eclipse.jface.action.IStatusLineManager; +import org.eclipse.jface.dialogs.MessageDialog; +import org.eclipse.swt.dnd.Clipboard; +import org.eclipse.swt.dnd.TextTransfer; +import org.eclipse.swt.dnd.Transfer; +import org.eclipse.swt.graphics.Color; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; + +/** + * What the Tables page offers the grids and their actions from the workbench around it: running an edit + * when the page is editable and reporting the refusal of one, the status line, the clipboard, the shell for + * dialogs, and whether the workbench theme is dark. All of it belongs to the page's control, so it may be + * used on the UI thread only. + */ +final class PageServices +{ + private final Control control; + + private final BooleanSupplier editable; + + private final BooleanSupplier inputStateValid; + + private final Supplier statusLine; + + /** + * Creates the services of a page. + * + * @param control The page's control, which provides the shell, the display, and the background color. + * @param editable Tells whether the page can be edited at the moment. + * @param inputStateValid Validates the editor input's state, which may ask the user to make a read-only + * file writable; true when the input can be edited. + * @param statusLine Returns the editor's status line. + */ + PageServices(final Control control, final BooleanSupplier editable, final BooleanSupplier inputStateValid, + final Supplier statusLine) + { + this.control = control; + this.editable = editable; + this.inputStateValid = inputStateValid; + this.statusLine = statusLine; + } + + boolean isDarkTheme() + { + return relativeLuminance(control.getBackground()) < 0.5; + } + + boolean executeEdit(final Runnable edit) + { + if (!editable.getAsBoolean()) + { + return false; + } + return executeWithValidInput(edit); + } + + /** + * Runs an edit that the page allows although the page is not editable, such as the creation of the first + * content of a blank document, when the input of the editor can be edited. A refusal of the edit is + * shown in the status line. + * + * @param edit The edit to run. + * @return True when the edit ran and was not refused. + */ + boolean executeWithValidInput(final Runnable edit) + { + if (!inputStateValid.getAsBoolean()) + { + return false; + } + try + { + edit.run(); + } + catch (final DatasetEditException e) + { + setStatusErrorMessage(e.getMessage()); + return false; + } + statusLine.get().setErrorMessage(null); + return true; + } + + boolean executeMultiCellEdit(final String title, final Runnable edit) + { + if (!editable.getAsBoolean() || !inputStateValid.getAsBoolean()) + { + return false; + } + try + { + edit.run(); + } + catch (final DatasetEditException e) + { + MessageDialog.openError(control.getShell(), title, e.getMessage()); + return false; + } + statusLine.get().setErrorMessage(null); + return true; + } + + Shell getShell() + { + return control.getShell(); + } + + void setStatusMessage(final String message) + { + statusLine.get().setMessage(message); + } + + void setStatusErrorMessage(final String message) + { + statusLine.get().setErrorMessage(message); + Display.getCurrent().beep(); + } + + void writeClipboardText(final String text) + { + final Clipboard clipboard = new Clipboard(control.getDisplay()); + try + { + clipboard.setContents(new Object[] { text }, new Transfer[] { TextTransfer.getInstance() }); + } + finally + { + clipboard.dispose(); + } + } + + String readClipboardText() + { + final Clipboard clipboard = new Clipboard(control.getDisplay()); + try + { + return (String) clipboard.getContents(TextTransfer.getInstance()); + } + finally + { + clipboard.dispose(); + } + } + + private static double relativeLuminance(final Color color) + { + return 0.2126 * linearize(color.getRed()) + 0.7152 * linearize(color.getGreen()) + + 0.0722 * linearize(color.getBlue()); + } + + private static double linearize(final int channelValue) + { + final double normalized = channelValue / 255.0; + return normalized <= 0.03928 ? normalized / 12.92 : Math.pow((normalized + 0.055) / 1.055, 2.4); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/ProblemsSection.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/ProblemsSection.java new file mode 100644 index 00000000..bf61e299 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/ProblemsSection.java @@ -0,0 +1,247 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.dbunit.eclipse.dataset.core.model.DatasetModel; +import org.dbunit.eclipse.dataset.core.model.DatasetProblem; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.dbunit.eclipse.dataset.core.model.ProblemSeverity; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.eclipse.jface.layout.TableColumnLayout; +import org.eclipse.jface.viewers.ArrayContentProvider; +import org.eclipse.jface.viewers.ColumnLabelProvider; +import org.eclipse.jface.viewers.ColumnLayoutData; +import org.eclipse.jface.viewers.ColumnPixelData; +import org.eclipse.jface.viewers.ColumnWeightData; +import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.jface.viewers.TableViewer; +import org.eclipse.jface.viewers.TableViewerColumn; +import org.eclipse.osgi.util.NLS; +import org.eclipse.swt.SWT; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Table; +import org.eclipse.ui.ISharedImages; +import org.eclipse.ui.PlatformUI; + +/** + * A section shown below {@link TablesPage}'s grid, listing the dataset model's problems in a table at most + * six rows high, and hidden when there are none. + * + * @since 1.0.0 + */ +final class ProblemsSection +{ + private static final int MAX_VISIBLE_ROWS = 6; + + private final Composite control; + + private final Label header; + + private final GridData tableData; + + private final TableViewer viewer; + + ProblemsSection(final Composite parent, final Consumer onDoubleClick) + { + control = new Composite(parent, SWT.NONE); + control.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); + final GridLayout layout = new GridLayout(1, false); + layout.marginWidth = 0; + layout.marginHeight = 0; + control.setLayout(layout); + + header = new Label(control, SWT.NONE); + header.setLayoutData(new GridData(SWT.FILL, SWT.TOP, true, false)); + + final Composite tableComposite = new Composite(control, SWT.NONE); + tableData = new GridData(SWT.FILL, SWT.FILL, true, true); + tableComposite.setLayoutData(tableData); + final TableColumnLayout columnLayout = new TableColumnLayout(); + tableComposite.setLayout(columnLayout); + viewer = new TableViewer(tableComposite, SWT.FULL_SELECTION | SWT.V_SCROLL | SWT.BORDER); + + addColumn(columnLayout, new ColumnPixelData(24), new SeverityIconProvider()); + addColumn(columnLayout, new ColumnPixelData(120), textProvider(ProblemRow::tableName)); + addColumn(columnLayout, new ColumnWeightData(100), textProvider(row -> row.problem().message())); + + viewer.setContentProvider(ArrayContentProvider.getInstance()); + viewer.addDoubleClickListener(event -> + { + final Object selected = + ((IStructuredSelection) event.getSelection()).getFirstElement(); + if (selected != null) + { + onDoubleClick.accept(((ProblemRow) selected).problem()); + } + }); + + hide(); + } + + Control getControl() + { + return control; + } + + String getHeaderText() + { + return header.getText(); + } + + void update(final DatasetModel model) + { + final List problems = problemRows(model); + if (problems.isEmpty()) + { + hide(); + return; + } + header.setText(NLS.bind(Messages.ProblemsSection_header, problems.size())); + viewer.setInput(problems.toArray()); + final Table table = viewer.getTable(); + final int visibleRows = Math.min(problems.size(), MAX_VISIBLE_ROWS); + tableData.heightHint = table.computeTrim(0, 0, 0, table.getItemHeight() * visibleRows).height; + reveal(); + // The trim can include room for a horizontal scroll bar that does not show. + final int surplus = table.getClientArea().height - table.getItemHeight() * visibleRows; + if (surplus > 0) + { + tableData.heightHint -= surplus; + reveal(); + } + } + + private static List problemRows(final DatasetModel model) + { + final List rows = new ArrayList<>(); + for (final DatasetProblem problem : model.getProblems()) + { + rows.add(new ProblemRow(problem, tableName(model, problem.tableKey()))); + } + return rows; + } + + private static String tableName(final DatasetModel model, final String tableKey) + { + if (tableKey == null) + { + return ""; + } + return model.findTable(tableKey).map(DatasetTable::getName).orElse(tableKey); + } + + Table getTable() + { + return viewer.getTable(); + } + + private void addColumn(final TableColumnLayout columnLayout, final ColumnLayoutData width, + final ColumnLabelProvider labelProvider) + { + final TableViewerColumn column = new TableViewerColumn(viewer, SWT.NONE); + columnLayout.setColumnData(column.getColumn(), width); + column.setLabelProvider(labelProvider); + } + + private void hide() + { + if (!isExcluded()) + { + setExcluded(true); + control.getParent().layout(); + } + } + + private void reveal() + { + setExcluded(false); + control.getParent().layout(true, true); + } + + private boolean isExcluded() + { + return ((GridData) control.getLayoutData()).exclude; + } + + private void setExcluded(final boolean excluded) + { + control.setVisible(!excluded); + ((GridData) control.getLayoutData()).exclude = excluded; + } + + private static Image severityImage(final ProblemSeverity severity) + { + final String key = switch (severity) + { + case ERROR -> ISharedImages.IMG_OBJS_ERROR_TSK; + case WARNING -> ISharedImages.IMG_OBJS_WARN_TSK; + case INFO -> ISharedImages.IMG_OBJS_INFO_TSK; + }; + return PlatformUI.getWorkbench().getSharedImages().getImage(key); + } + + private static ColumnLabelProvider textProvider(final Function text) + { + return new ColumnLabelProvider() + { + @Override + public String getText(final Object element) + { + return text.apply((ProblemRow) element); + } + }; + } + + /** + * Shows a problem's severity as an icon, without text. + */ + private static final class SeverityIconProvider extends ColumnLabelProvider + { + @Override + public String getText(final Object element) + { + return ""; + } + + @Override + public Image getImage(final Object element) + { + return severityImage(((ProblemRow) element).problem().severity()); + } + } + + /** + * A problem as the section lists it, with the name of its table as the table's tab shows it. + */ + private record ProblemRow(DatasetProblem problem, String tableName) + { + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/SaveEncoding.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/SaveEncoding.java new file mode 100644 index 00000000..bfbfa69a --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/SaveEncoding.java @@ -0,0 +1,136 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import java.io.IOException; +import java.io.StringReader; +import java.util.Optional; +import java.util.function.Supplier; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.runtime.Adapters; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.ILog; +import org.eclipse.core.runtime.Platform; +import org.eclipse.core.runtime.QualifiedName; +import org.eclipse.core.runtime.content.IContentDescription; +import org.eclipse.jface.text.BadLocationException; +import org.eclipse.jface.text.IDocument; +import org.eclipse.ui.IEditorInput; + +/** + * Works out which encoding a save of the Source page writes the text in, as far as the text decides it. When + * the text file buffer saves, it takes the encoding that was set for the file, if there is one, else the + * encoding that the content type manager makes out from the text as it is at that moment, which is the one + * that its XML declaration names or else the default of its content type, and else the encoding that the + * buffer had when the file was loaded. The encoding that the buffer reports does not follow an edit of the + * declaration, so the dataset editor asks this class first, and leaves the last step to the buffer. + * + * @since 1.0.0 + */ +final class SaveEncoding +{ + private static final ILog LOG = ILog.of(SaveEncoding.class); + + private static final QualifiedName[] OPTIONS = { IContentDescription.CHARSET }; + + /** + * How many characters from the start of the text the content type manager gets: far more than an XML + * declaration, and enough for the describers to reach the root element. + */ + private static final int PROBE_LENGTH = 8192; + + private final Supplier input; + + private final Supplier document; + + /** + * Creates the finder for the text of the Source page. Both parts are asked for each time, because the + * input and its document change when the file is saved under another name. + * + * @param input Returns the editor input of the Source page. + * @param document Returns the document with the text of the Source page. + */ + SaveEncoding(final Supplier input, final Supplier document) + { + this.input = input; + this.document = document; + } + + /** + * Returns the encoding that the text decides. + * + * @return The name of the encoding, or empty when the file has an encoding of its own, which a save + * takes whatever the text says, or when no content type makes out an encoding for the text, so + * that the encoding of the buffer is the one. + */ + Optional decidedByText() + { + final IEditorInput editorInput = input.get(); + if (fileHasEncodingOfItsOwn(editorInput)) + { + return Optional.empty(); + } + return encodingOfText(editorInput.getName()); + } + + private static boolean fileHasEncodingOfItsOwn(final IEditorInput editorInput) + { + final IFile file = Adapters.adapt(editorInput, IFile.class); + if (file == null) + { + return false; + } + try + { + final String charset = file.getCharset(false); + return charset != null; + } + catch (final CoreException e) + { + LOG.log(e.getStatus()); + return false; + } + } + + private Optional encodingOfText(final String fileName) + { + final IDocument text = document.get(); + final int length = Math.min(text.getLength(), PROBE_LENGTH); + try + { + final String start = text.get(0, length); + final IContentDescription description = Platform.getContentTypeManager() + .getDescriptionFor(new StringReader(start), fileName, OPTIONS); + if (description == null) + { + return Optional.empty(); + } + return Optional.ofNullable(description.getCharset()); + } + catch (final IOException | BadLocationException e) + { + LOG.warn("The encoding of the text could not be made out, so the encoding of the file buffer is " + + "used.", e); + return Optional.empty(); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/SharedHistoryAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/SharedHistoryAction.java new file mode 100644 index 00000000..f8b6b7d7 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/SharedHistoryAction.java @@ -0,0 +1,93 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import java.util.function.BooleanSupplier; + +import org.eclipse.jface.action.Action; +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.util.IPropertyChangeListener; + +/** + * The Tables page's Undo or Redo: runs the Source page's action on the history that both pages share, and + * shows the label, the tool tip, and the enablement that the Source page's action has, so that both pages + * name the operation in the same way. It does nothing while a cell editor is active. The model and the grids + * catch up before the action returns, because a command that is already queued behind it would otherwise act + * on the rows that the undo or redo removed. + * + * @since 1.0.0 + */ +final class SharedHistoryAction extends Action +{ + private final IAction sourceAction; + + private final BooleanSupplier hasActiveCellEditor; + + private final Runnable refreshModel; + + private final IPropertyChangeListener sourceActionListener = event -> followSourceAction(); + + /** + * Creates an undo or a redo action that runs the Source page's own. + * + * @param sourceAction The Source page's undo or redo action, which acts on the history of the document + * and tells when its label, its tool tip, or its enablement changes. + * @param hasActiveCellEditor Returns true while a grid cell editor is open, so that the action declines + * to run. + * @param refreshModel Brings the model and the grids up to date with the document, and runs once the + * history has changed it. + */ + SharedHistoryAction(final IAction sourceAction, final BooleanSupplier hasActiveCellEditor, + final Runnable refreshModel) + { + this.sourceAction = sourceAction; + this.hasActiveCellEditor = hasActiveCellEditor; + this.refreshModel = refreshModel; + sourceAction.addPropertyChangeListener(sourceActionListener); + followSourceAction(); + } + + @Override + public void run() + { + if (hasActiveCellEditor.getAsBoolean()) + { + return; + } + sourceAction.run(); + refreshModel.run(); + } + + /** + * Stops following the Source page's action. + */ + void dispose() + { + sourceAction.removePropertyChangeListener(sourceActionListener); + } + + private void followSourceAction() + { + setText(sourceAction.getText()); + setToolTipText(sourceAction.getToolTipText()); + setEnabled(sourceAction.isEnabled()); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/SourceStatusFields.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/SourceStatusFields.java new file mode 100644 index 00000000..5d800ac2 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/SourceStatusFields.java @@ -0,0 +1,176 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import java.util.List; + +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.action.IStatusLineManager; +import org.eclipse.ui.texteditor.ITextEditorActionConstants; +import org.eclipse.ui.texteditor.ITextEditorActionDefinitionIds; +import org.eclipse.ui.texteditor.StatusLineContributionItem; + +/** + * The status line fields of the Source page: whether the file is writable, the input mode, and the line and + * column of the text cursor, which a text editor shows through the fields of its own action bar + * contributor. The Source page is nested in the dataset editor, so the dataset editor's contributor adds + * the fields to the status line, shows them for the Source page, and hides them for the Tables page, where + * they mean nothing. It may be used on the UI thread only. + */ +final class SourceStatusFields +{ + private static final int FIND_FIELD_WIDTH_IN_CHARS = 40; + + private static final int ELEMENT_STATE_WIDTH_IN_CHARS = 20; + + private static final int POSITION_AND_MODE_WIDTH_IN_CHARS = 19; + + /** + * A field of the status line. + * + * @param category The category that the text editor knows the field by. + * @param actionId The id of the editor's action that a double click on the field runs, or null for none. + * @param shownWithEditor True when the field is visible whenever the Source page is, false for a field + * that the editor shows only while it needs it, such as the incremental find. + * @param item The item that the status line shows. + */ + private record Field(String category, String actionId, boolean shownWithEditor, + StatusLineContributionItem item) + { + } + + private final List fields = List.of( + field(ITextEditorActionConstants.STATUS_CATEGORY_FIND_FIELD, null, false, + FIND_FIELD_WIDTH_IN_CHARS), + field(ITextEditorActionConstants.STATUS_CATEGORY_ELEMENT_STATE, null, true, + ELEMENT_STATE_WIDTH_IN_CHARS), + field(ITextEditorActionConstants.STATUS_CATEGORY_INPUT_MODE, + ITextEditorActionDefinitionIds.TOGGLE_OVERWRITE, true, POSITION_AND_MODE_WIDTH_IN_CHARS), + field(ITextEditorActionConstants.STATUS_CATEGORY_INPUT_POSITION, + ITextEditorActionConstants.GOTO_LINE, true, POSITION_AND_MODE_WIDTH_IN_CHARS)); + + private IStatusLineManager statusLineManager; + + private FlatXmlSourceEditor shownEditor; + + /** + * Adds the fields to a status line, where they stay hidden until an editor is shown. + * + * @param manager The manager of the status line that the fields belong to. + */ + void contributeTo(final IStatusLineManager manager) + { + statusLineManager = manager; + for (final Field field : fields) + { + manager.add(field.item()); + } + } + + /** + * Shows the fields for an editor, which keeps them up to date from now on, and stops the editor that + * they were shown for before from doing so. + * + * @param editor The editor of the Source page that is shown. + */ + void showFor(final FlatXmlSourceEditor editor) + { + detach(); + shownEditor = editor; + for (final Field field : fields) + { + final StatusLineContributionItem item = field.item(); + item.setActionHandler(actionOf(editor, field.actionId())); + editor.setStatusField(item, field.category()); + item.setVisible(field.shownWithEditor()); + } + updateStatusLine(); + } + + /** + * Hides the fields and stops the editor that they were shown for from keeping them up to date. + */ + void hide() + { + detach(); + for (final Field field : fields) + { + field.item().setVisible(false); + } + updateStatusLine(); + } + + /** + * Returns the item of a field, for a test to read what the field shows. + * + * @param category The category of the field, one of the {@code STATUS_CATEGORY} constants of + * {@link ITextEditorActionConstants}. + * @return The item of the field. + */ + StatusLineContributionItem item(final String category) + { + for (final Field field : fields) + { + if (field.category().equals(category)) + { + return field.item(); + } + } + throw new IllegalArgumentException("There is no status field of the category " + category + "."); + } + + private void detach() + { + if (shownEditor == null) + { + return; + } + for (final Field field : fields) + { + shownEditor.setStatusField(null, field.category()); + } + shownEditor = null; + } + + private void updateStatusLine() + { + if (statusLineManager != null) + { + statusLineManager.update(true); + } + } + + private static IAction actionOf(final FlatXmlSourceEditor editor, final String actionId) + { + if (actionId == null) + { + return null; + } + return editor.getAction(actionId); + } + + private static Field field(final String category, final String actionId, final boolean shownWithEditor, + final int widthInChars) + { + final StatusLineContributionItem item = new StatusLineContributionItem(category, false, widthInChars); + return new Field(category, actionId, shownWithEditor, item); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/SystemIdentifier.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/SystemIdentifier.java new file mode 100644 index 00000000..fa689559 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/SystemIdentifier.java @@ -0,0 +1,129 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; + +/** + * The system identifier of a DOCTYPE read as the XML parser of dbUnit reads it as a URI reference: where the + * file system separates names with a backslash, a backslash is a slash, and a character that a URI cannot + * hold, such as a space, is escaped with the percent sign, while an escape sequence that is there already + * stays as it is. A path such as {@code test data/dataset.dtd} or {@code dtd\dataset.dtd} is no URI as it is + * written, but the parser loads the file that it names. + * + * @since 1.0.0 + */ +final class SystemIdentifier +{ + private static final String HEXADECIMAL_DIGITS = "0123456789ABCDEF"; + + private static final String PUNCTUATION = "-_.!~*'()" + ";/?:@&=+$,#"; + + private final String text; + + private final boolean backslashIsSeparator; + + /** + * Creates the identifier. + * + * @param text The system identifier as the DOCTYPE writes it. + * @param backslashIsSeparator True when the file system separates names with a backslash, so that a + * backslash in the identifier is a slash. + */ + SystemIdentifier(final String text, final boolean backslashIsSeparator) + { + this.text = text; + this.backslashIsSeparator = backslashIsSeparator; + } + + /** + * Returns the URI reference that the identifier stands for. + * + * @return The URI reference, with each character that a URI cannot hold escaped. + * @throws URISyntaxException When the escaped text is still no URI reference. + */ + URI toUri() throws URISyntaxException + { + return new URI(escaped()); + } + + /** + * Returns the identifier with each character that a URI cannot hold escaped, as UTF-8 bytes. + * + * @return The escaped text. + */ + String escaped() + { + final String slashed = backslashIsSeparator ? text.replace('\\', '/') : text; + final StringBuilder result = new StringBuilder(slashed.length()); + int index = 0; + while (index < slashed.length()) + { + final int codePoint = slashed.codePointAt(index); + if (isKept(slashed, index, codePoint)) + { + result.appendCodePoint(codePoint); + } + else + { + appendEscaped(result, codePoint); + } + index += Character.charCount(codePoint); + } + return result.toString(); + } + + private static boolean isKept(final String value, final int index, final int codePoint) + { + if (codePoint == '%') + { + return startsEscapeSequence(value, index); + } + if (codePoint > 127) + { + return !Character.isISOControl(codePoint) && !Character.isSpaceChar(codePoint); + } + return Character.isLetterOrDigit(codePoint) || PUNCTUATION.indexOf(codePoint) >= 0; + } + + private static boolean startsEscapeSequence(final String value, final int index) + { + return index + 2 < value.length() && isHexadecimalDigit(value.charAt(index + 1)) + && isHexadecimalDigit(value.charAt(index + 2)); + } + + private static boolean isHexadecimalDigit(final char ch) + { + return Character.digit(ch, 16) >= 0 && ch < 128; + } + + private static void appendEscaped(final StringBuilder result, final int codePoint) + { + final byte[] bytes = new String(Character.toChars(codePoint)).getBytes(StandardCharsets.UTF_8); + for (final byte value : bytes) + { + result.append('%').append(HEXADECIMAL_DIGITS.charAt((value >> 4) & 0xF)) + .append(HEXADECIMAL_DIGITS.charAt(value & 0xF)); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/TableTabs.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/TableTabs.java new file mode 100644 index 00000000..30401653 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/TableTabs.java @@ -0,0 +1,387 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.dbunit.eclipse.dataset.core.edit.TableChanges; +import org.dbunit.eclipse.dataset.core.model.CellAddress; +import org.dbunit.eclipse.dataset.core.model.DatasetModel; +import org.dbunit.eclipse.dataset.core.model.DatasetProblem; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.dbunit.eclipse.dataset.core.model.ProblemSeverity; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGrid; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.eclipse.jface.resource.FontDescriptor; +import org.eclipse.jface.resource.ResourceManager; +import org.eclipse.osgi.util.NLS; +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.CTabFolder; +import org.eclipse.swt.custom.CTabItem; +import org.eclipse.swt.graphics.Font; +import org.eclipse.swt.graphics.Image; +import org.eclipse.ui.ISharedImages; +import org.eclipse.ui.PlatformUI; + +/** + * The sheet tabs of the Tables page: one tab with a {@link DatasetGrid} for each table of the dataset model, + * in the order of the model, with the name, the tooltip, the font, and the problem icon of each tab. The page + * tells the tabs which tables of the model are tables that they show under other keys, so that a renamed + * table keeps its tab and its grid, and which table's tab to select. A tab stays selected when the tabs + * move, and when the selected table is gone, the tab that takes its place is selected. All of it belongs to + * the tab folder, so it may be used on the UI thread only. + */ +final class TableTabs +{ + private final CTabFolder tabFolder; + + private final DatasetGridContext context; + + private final ResourceManager resources; + + private final Runnable gridStateChanged; + + private final Map tabsByKey = new LinkedHashMap<>(); + + /** + * Creates the tabs of a page. + * + * @param tabFolder The folder that holds the tabs. + * @param context The context that each grid works with. + * @param resources The manager that owns the fonts of the tabs and disposes them with the page. + * @param gridStateChanged Runs when the selected tab changes, when the selection of a grid changes, and + * when a cell editor of a grid opens or closes. + */ + TableTabs(final CTabFolder tabFolder, final DatasetGridContext context, final ResourceManager resources, + final Runnable gridStateChanged) + { + this.tabFolder = tabFolder; + this.context = context; + this.resources = resources; + this.gridStateChanged = gridStateChanged; + } + + /** + * Commits the value of an open cell editor, so that it is in the document before the document is saved + * or the Source page shows it. + * + * @return True when no cell editor is open anymore; false when the value of one was not accepted and + * its editor stays open, so that the value is not in the document. + */ + boolean commitActiveCellEditor() + { + boolean noEditorLeft = true; + for (final TableTab tab : tabsByKey.values()) + { + final boolean closed = tab.grid().commitActiveCellEditor(); + noEditorLeft = noEditorLeft && closed; + } + return noEditorLeft; + } + + /** + * Closes an open cell editor without writing its value, so that the value cannot reach a document that + * is about to be replaced. + */ + void cancelActiveCellEditor() + { + for (final TableTab tab : tabsByKey.values()) + { + tab.grid().cancelActiveCellEditor(); + } + } + + /** + * Repaints every grid, for example after the NULL display text changed. + */ + void repaintGrids() + { + for (final TableTab tab : tabsByKey.values()) + { + tab.grid().repaint(); + } + } + + /** + * Has every grid apply the theme of the workbench again when it changed. + */ + void updateThemes() + { + for (final TableTab tab : tabsByKey.values()) + { + tab.grid().updateTheme(); + } + } + + DatasetGrid activeGrid() + { + final CTabItem selected = tabFolder.getSelection(); + for (final TableTab tab : tabsByKey.values()) + { + if (tab.item().equals(selected)) + { + return tab.grid(); + } + } + return null; + } + + void selectCell(final CellAddress address) + { + final TableTab tab = tabsByKey.get(address.tableKey()); + if (tab == null) + { + return; + } + tabFolder.setSelection(tab.item()); + gridStateChanged.run(); + if (address.columnIndex() >= 0) + { + tab.grid().selectCell(address.columnIndex(), address.rowIndex()); + } + } + + /** + * Brings the tabs in line with the tables of a model, when nothing is known about how they came from + * the tables that the tabs show: a table under a new key gets a new tab. + * + * @param model The model whose tables the tabs show. + */ + void reconcile(final DatasetModel model) + { + reconcile(model, TableChanges.NONE, null); + } + + /** + * Brings the tabs in line with the tables of the model. A table that was renamed keeps its tab and its + * grid under its new key. The tabs of tables that are gone are disposed first, so that they do not make + * the tabs after them move, then the tab of each table is created or moved to the table's place and + * updated. The tab that was selected stays selected, also when it moves, and when its table is gone, the + * tab that takes its place is selected. The tab of the table to select is selected in any case. + * + * @param model The model whose tables the tabs show. + * @param changes Which tables of the model are tables that the tabs show under other keys. + * @param tableKeyToSelect The key of the table whose tab is selected, or null to keep the selection. + */ + void reconcile(final DatasetModel model, final TableChanges changes, final String tableKeyToSelect) + { + final Map tables = tablesByKey(model); + final DatasetGrid selectedGrid = activeGrid(); + final int selectedIndex = tabFolder.getSelectionIndex(); + + applyRenames(changes.renamedKeys(), tables.keySet()); + disposeStaleTabs(tables.keySet()); + int index = 0; + for (final DatasetTable table : tables.values()) + { + final TableTab tab = placeTab(table.getKey(), index); + tab.grid().tableChanged(table); + updateTab(tab.item(), table, model); + index++; + } + selectTab(selectedGrid, selectedIndex, tableKeyToSelect); + } + + private static Map tablesByKey(final DatasetModel model) + { + final Map tables = new LinkedHashMap<>(); + for (final DatasetTable table : model.getTables()) + { + tables.putIfAbsent(table.getKey(), table); + } + return tables; + } + + private void applyRenames(final Map renamedKeys, final Set tableKeys) + { + for (final Map.Entry renamed : renamedKeys.entrySet()) + { + final String oldKey = renamed.getKey(); + final String newKey = renamed.getValue(); + final boolean renameApplies = tableKeys.contains(newKey) && tabsByKey.containsKey(oldKey) + && !tabsByKey.containsKey(newKey); + if (renameApplies) + { + final TableTab tab = tabsByKey.remove(oldKey); + tab.grid().tableRenamed(newKey); + tabsByKey.put(newKey, tab); + } + } + } + + private void disposeStaleTabs(final Set tableKeys) + { + final Iterator> iterator = tabsByKey.entrySet().iterator(); + while (iterator.hasNext()) + { + final Map.Entry entry = iterator.next(); + if (!tableKeys.contains(entry.getKey())) + { + final CTabItem item = entry.getValue().item(); + item.getControl().dispose(); + item.dispose(); + iterator.remove(); + } + } + } + + private TableTab placeTab(final String key, final int index) + { + TableTab tab = tabsByKey.get(key); + if (tab == null) + { + tab = createTab(key, index); + } + else if (tabFolder.indexOf(tab.item()) != index) + { + tab = new TableTab(moveTab(tab.item(), index), tab.grid()); + tabsByKey.put(key, tab); + } + return tab; + } + + private TableTab createTab(final String key, final int index) + { + final DatasetGrid grid = new DatasetGrid(tabFolder, context, key); + grid.selectCell(0, 0); + grid.addSelectionListener(gridStateChanged); + grid.addCellEditorListener(gridStateChanged); + final CTabItem item = new CTabItem(tabFolder, SWT.NONE, index); + item.setControl(grid.getControl()); + final TableTab tab = new TableTab(item, grid); + tabsByKey.put(key, tab); + return tab; + } + + // A tab folder cannot move a tab, so the tab is created again at its new index. The new tab shares the + // grid and takes over the selection before the old tab is disposed, so the folder never selects another + // tab in between and never hides the grid. + private CTabItem moveTab(final CTabItem oldItem, final int index) + { + final CTabItem newItem = new CTabItem(tabFolder, SWT.NONE, index); + newItem.setControl(oldItem.getControl()); + if (oldItem.equals(tabFolder.getSelection())) + { + tabFolder.setSelection(newItem); + } + oldItem.dispose(); + return newItem; + } + + private void selectTab(final DatasetGrid previousGrid, final int previousIndex, + final String tableKeyToSelect) + { + final TableTab tabToSelect = tabsByKey.get(tableKeyToSelect); + if (tabToSelect == null) + { + if (!showsGrid(previousGrid)) + { + selectTabNearIndex(previousIndex); + } + } + else + { + tabFolder.setSelection(tabToSelect.item()); + } + } + + private boolean showsGrid(final DatasetGrid grid) + { + for (final TableTab tab : tabsByKey.values()) + { + if (tab.grid().equals(grid)) + { + return true; + } + } + return false; + } + + private void selectTabNearIndex(final int index) + { + final int lastIndex = tabFolder.getItemCount() - 1; + if (lastIndex >= 0) + { + tabFolder.setSelection(Math.max(0, Math.min(index, lastIndex))); + } + } + + private void updateTab(final CTabItem item, final DatasetTable table, final DatasetModel model) + { + item.setText(table.getName()); + final String tooltip = table.isDeclaredOnly() ? Messages.TablesPage_declaredOnlyTableTooltip + : countsTooltip(table); + item.setToolTipText(tooltip); + item.setFont(table.getRows().isEmpty() ? italicFont() : null); + item.setImage(problemImage(model.getProblems(table.getKey()))); + } + + private static String countsTooltip(final DatasetTable table) + { + final int rowCount = table.getRows().size(); + final int columnCount = table.getColumns().size(); + final String rows = rowCount == 1 ? Messages.TablesPage_tableTooltipOneRow + : NLS.bind(Messages.TablesPage_tableTooltipRows, rowCount); + final String columns = columnCount == 1 ? Messages.TablesPage_tableTooltipOneColumn + : NLS.bind(Messages.TablesPage_tableTooltipColumns, columnCount); + return NLS.bind(Messages.TablesPage_tableTooltip, rows, columns); + } + + private Font italicFont() + { + final FontDescriptor descriptor = FontDescriptor.createFrom(tabFolder.getFont()).setStyle(SWT.ITALIC); + return resources.createFont(descriptor); + } + + private static Image problemImage(final List problems) + { + boolean hasWarning = false; + for (final DatasetProblem problem : problems) + { + if (problem.severity() == ProblemSeverity.ERROR) + { + return sharedImage(ISharedImages.IMG_OBJS_ERROR_TSK); + } + if (problem.severity() == ProblemSeverity.WARNING) + { + hasWarning = true; + } + } + return hasWarning ? sharedImage(ISharedImages.IMG_OBJS_WARN_TSK) : null; + } + + private static Image sharedImage(final String key) + { + return PlatformUI.getWorkbench().getSharedImages().getImage(key); + } + + /** + * The tab of a table with the grid that it shows. + */ + private record TableTab(CTabItem item, DatasetGrid grid) + { + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/TablesPage.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/TablesPage.java new file mode 100644 index 00000000..b1b0edb2 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/TablesPage.java @@ -0,0 +1,617 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import java.util.List; + +import org.dbunit.eclipse.dataset.core.edit.ChangeOrigin; +import org.dbunit.eclipse.dataset.core.edit.DatasetDocument; +import org.dbunit.eclipse.dataset.core.edit.DatasetModelChangeEvent; +import org.dbunit.eclipse.dataset.core.edit.TableChanges; +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.core.model.CellAddress; +import org.dbunit.eclipse.dataset.core.model.DatasetModel; +import org.dbunit.eclipse.dataset.core.model.DatasetProblem; +import org.dbunit.eclipse.dataset.ui.DatasetUiPlugin; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.dbunit.eclipse.dataset.ui.preferences.PreferenceKeys; +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.action.IMenuManager; +import org.eclipse.jface.resource.JFaceResources; +import org.eclipse.jface.resource.LocalResourceManager; +import org.eclipse.jface.text.DocumentEvent; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.IDocumentListener; +import org.eclipse.jface.text.IRegion; +import org.eclipse.jface.text.ITextSelection; +import org.eclipse.jface.text.Region; +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.CTabFolder; +import org.eclipse.swt.custom.StackLayout; +import org.eclipse.swt.events.SelectionListener; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.layout.GridData; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; +import org.eclipse.ui.actions.ActionFactory; +import org.eclipse.ui.texteditor.ITextEditor; +import org.eclipse.ui.texteditor.ITextEditorActionConstants; + +/** + * The Tables page of {@link FlatXmlDatasetEditor}: one sheet tab per table, an {@link ErrorBanner}, and a + * blank-document state. + * + * @since 1.0.0 + */ +final class TablesPage implements DatasetGridContext +{ + private final FlatXmlDatasetEditor editor; + + private final FlatXmlDatasetDocument datasetDocument; + + private final LocalResourceManager resources; + + private final Composite control; + + private final PageServices services; + + private final ErrorBanner errorBanner; + + private final Composite contentStack; + + private final StackLayout contentStackLayout; + + private final ProblemsSection problemsSection; + + private final PageSelectionSync pageSelectionSync; + + private final Composite blankComposite; + + private final CTabFolder tabFolder; + + private final TableTabs tabs; + + private final ActiveGrid activeGrid; + + private final TablesPageActions actions; + + private final Button createEmptyDatasetButton; + + private final Composite noTablesComposite; + + private final Button addTableButton; + + private final IDocumentListener sourceDocumentListener = new IDocumentListener() + { + @Override + public void documentAboutToBeChanged(final DocumentEvent event) + { + // Nothing to do before a change: documentChanged schedules a refresh afterwards. + } + + @Override + public void documentChanged(final DocumentEvent event) + { + if (active) + { + scheduleRefresh(); + } + } + }; + + private boolean active; + + private boolean refreshScheduled; + + private IDocument listenedDocument; + + private boolean editable; + + TablesPage(final Composite parent, final FlatXmlDatasetEditor editor, + final FlatXmlDatasetDocument datasetDocument) + { + this.editor = editor; + this.datasetDocument = datasetDocument; + resources = new LocalResourceManager(JFaceResources.getResources(), parent); + pageSelectionSync = new PageSelectionSync(this::sourceSelectionRegion, + editor.getSourceEditor()::selectAndReveal, datasetDocument); + + control = new Composite(parent, SWT.NONE); + final GridLayout controlLayout = new GridLayout(1, false); + controlLayout.marginWidth = 0; + controlLayout.marginHeight = 0; + control.setLayout(controlLayout); + + services = new PageServices(control, () -> editable, + () -> editor.getSourceEditor().validateEditorInputState(), + () -> editor.getEditorSite().getActionBars().getStatusLineManager()); + errorBanner = new ErrorBanner(control, editor); + + contentStack = new Composite(control, SWT.NONE); + contentStack.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); + contentStackLayout = new StackLayout(); + contentStack.setLayout(contentStackLayout); + + problemsSection = new ProblemsSection(control, this::selectProblem); + + blankComposite = new Composite(contentStack, SWT.NONE); + blankComposite.setLayout(new GridLayout(1, false)); + final Label blankLabel = new Label(blankComposite, SWT.CENTER); + blankLabel.setText(Messages.TablesPage_emptyFile); + blankLabel.setLayoutData(new GridData(SWT.CENTER, SWT.BOTTOM, true, true)); + createEmptyDatasetButton = new Button(blankComposite, SWT.PUSH); + createEmptyDatasetButton.setText(Messages.TablesPage_createEmptyDataset); + createEmptyDatasetButton.setLayoutData(new GridData(SWT.CENTER, SWT.TOP, true, true)); + createEmptyDatasetButton.addSelectionListener( + SelectionListener.widgetSelectedAdapter(event -> createEmptyDataset())); + + noTablesComposite = new Composite(contentStack, SWT.NONE); + noTablesComposite.setLayout(new GridLayout(1, false)); + final Label noTablesLabel = new Label(noTablesComposite, SWT.CENTER); + noTablesLabel.setText(Messages.TablesPage_noTables); + noTablesLabel.setLayoutData(new GridData(SWT.CENTER, SWT.BOTTOM, true, true)); + addTableButton = new Button(noTablesComposite, SWT.PUSH); + addTableButton.setText(Messages.Action_addTable); + addTableButton.setLayoutData(new GridData(SWT.CENTER, SWT.TOP, true, true)); + addTableButton.addSelectionListener( + SelectionListener.widgetSelectedAdapter(event -> runAddTableAction())); + + tabFolder = new CTabFolder(contentStack, SWT.TOP | SWT.BORDER | SWT.FLAT); + final ITextEditor sourceEditor = editor.getSourceEditor(); + actions = new TablesPageActions(this, tabFolder, editor::getEditorSite, + sourceEditor.getAction(ITextEditorActionConstants.UNDO), + sourceEditor.getAction(ITextEditorActionConstants.REDO), this::hasActiveCellEditor, + this::getSelection); + tabs = new TableTabs(tabFolder, this, resources, actions::updateGridActionsEnablement); + activeGrid = new ActiveGrid(tabFolder, tabs::activeGrid); + + final IDocument document = sourceDocument(); + listenedDocument = document; + document.addDocumentListener(sourceDocumentListener); + datasetDocument.addModelListener(event -> reconcile(event)); + + reconcile(); + } + + Control getControl() + { + return control; + } + + /** + * Commits the value of an open cell editor, so that it is in the document before the document is saved + * or the Source page shows it. + * + * @return True when no cell editor is open anymore; false when the value of one was not accepted and + * its editor stays open, so that the value is not in the document. + */ + boolean commitActiveCellEditor() + { + return tabs.commitActiveCellEditor(); + } + + /** + * Closes an open cell editor without writing its value, so that the value cannot reach the document + * after a revert has replaced the text that the editor was opened on. + */ + void cancelActiveCellEditor() + { + tabs.cancelActiveCellEditor(); + } + + void activate() + { + active = true; + actions.activate(); + datasetDocument.refresh(); + pageSelectionSync.onActivate().ifPresent(tabs::selectCell); + } + + void deactivate() + { + pageSelectionSync.onDeactivate(activeGrid.currentCellAddress()); + active = false; + actions.deactivate(); + } + + /** + * Follows the editor to the document of its new input, after Save As or a move of its file. + */ + void inputChanged() + { + listenedDocument.removeDocumentListener(sourceDocumentListener); + listenedDocument = sourceDocument(); + listenedDocument.addDocumentListener(sourceDocumentListener); + } + + /** + * Stops listening to the document, which can outlive the editor when another editor shares it. + */ + void dispose() + { + listenedDocument.removeDocumentListener(sourceDocumentListener); + actions.dispose(); + } + + /** + * Returns this page's action for a global action id, for {@link DatasetEditorContributor} to install + * while the Tables page is active. + * + * @param actionDefinitionId One of {@link ActionFactory}'s global action ids. + * @return The action, or null when this page has none for that id. + */ + IAction getGlobalActionHandler(final String actionDefinitionId) + { + return actions.getGlobalActionHandler(actionDefinitionId); + } + + /** + * Repaints every grid, for example after the NULL display text changed. + */ + void repaintGrids() + { + tabs.repaintGrids(); + } + + @Override + public boolean isEditable() + { + return editable; + } + + @Override + public DatasetDocument getDatasetDocument() + { + return datasetDocument; + } + + @Override + public String getNullDisplayText() + { + return DatasetUiPlugin.getDefault().getPreferenceStore().getString(PreferenceKeys.NULL_DISPLAY_TEXT); + } + + @Override + public boolean isDarkTheme() + { + return services.isDarkTheme(); + } + + @Override + public boolean executeEdit(final Runnable edit) + { + return services.executeEdit(edit); + } + + @Override + public boolean executeMultiCellEdit(final String title, final Runnable edit) + { + return services.executeMultiCellEdit(title, edit); + } + + @Override + public void fillContextMenu(final IMenuManager menu, final String region) + { + actions.fillContextMenu(menu, region); + } + + @Override + public boolean hasActiveCellEditor() + { + return activeGrid.hasActiveCellEditor(); + } + + @Override + public GridSelection getSelection() + { + return activeGrid.getSelection(); + } + + @Override + public void selectRegion(final int firstColumnIndex, final int firstRowIndex, final int columnCount, + final int rowCount) + { + activeGrid.selectRegion(firstColumnIndex, firstRowIndex, columnCount, rowCount); + } + + @Override + public Shell getShell() + { + return services.getShell(); + } + + @Override + public Text getActiveCellEditorText() + { + return activeGrid.getActiveCellEditorText(); + } + + @Override + public List getSelectedCellPositions() + { + return activeGrid.getSelectedCellPositions(); + } + + @Override + public void selectAll() + { + activeGrid.selectAll(); + } + + @Override + public void editCellInDialog() + { + activeGrid.editCellInDialog(); + } + + @Override + public void showInSource() + { + final CellAddress address = activeGrid.currentCellAddress(); + if (address == null) + { + return; + } + datasetDocument.locate(address) + .ifPresent(region -> editor.showOnSourcePage(region.getOffset(), region.getLength())); + } + + @Override + public void setStatusMessage(final String message) + { + services.setStatusMessage(message); + } + + @Override + public void setStatusErrorMessage(final String message) + { + services.setStatusErrorMessage(message); + } + + @Override + public void writeClipboardText(final String text) + { + services.writeClipboardText(text); + } + + @Override + public String readClipboardText() + { + return services.readClipboardText(); + } + + CTabFolder getTabFolder() + { + return tabFolder; + } + + ErrorBanner getErrorBanner() + { + return errorBanner; + } + + ProblemsSection getProblemsSection() + { + return problemsSection; + } + + boolean isActive() + { + return active; + } + + boolean isShowingBlankState() + { + return blankComposite.equals(contentStackLayout.topControl); + } + + Button getCreateEmptyDatasetButton() + { + return createEmptyDatasetButton; + } + + boolean isShowingNoTablesState() + { + return noTablesComposite.equals(contentStackLayout.topControl); + } + + Button getAddTableButton() + { + return addTableButton; + } + + private IDocument sourceDocument() + { + final ITextEditor sourceEditor = editor.getSourceEditor(); + return sourceEditor.getDocumentProvider().getDocument(sourceEditor.getEditorInput()); + } + + void selectProblem(final DatasetProblem problem) + { + if (problem.tableKey() == null) + { + editor.showOnSourcePage(problem.offset(), problem.length()); + return; + } + final int columnIndex = columnIndexOf(problem); + tabs.selectCell(new CellAddress(problem.tableKey(), Math.max(problem.rowIndex(), 0), columnIndex)); + if (columnIndex < 0 && problem.offset() >= 0) + { + editor.showOnSourcePage(problem.offset(), problem.length()); + } + } + + /** + * Returns the index of the column that a problem names, or -1 when it names none, or one that its table + * does not have. + */ + private int columnIndexOf(final DatasetProblem problem) + { + if (problem.columnName() == null) + { + return -1; + } + return datasetDocument.getModel().findTable(problem.tableKey()) + .map(table -> table.getColumnIndex(problem.columnName())).orElse(-1); + } + + private IRegion sourceSelectionRegion() + { + final ITextSelection selection = + (ITextSelection) editor.getSourceEditor().getSelectionProvider().getSelection(); + return new Region(selection.getOffset(), selection.getLength()); + } + + private void scheduleRefresh() + { + if (refreshScheduled) + { + return; + } + refreshScheduled = true; + control.getDisplay().asyncExec(() -> + { + refreshScheduled = false; + if (control.isDisposed()) + { + return; + } + datasetDocument.refresh(); + }); + } + + private void reconcile() + { + reconcile(TableChanges.NONE, null); + } + + /** + * Brings the page in line with a change of the model. A table that an edit added gets its tab selected; + * a table that appears any other way, as after an undo or a change on the Source page, does not take + * the selection from the table that the user is working on. + */ + private void reconcile(final DatasetModelChangeEvent event) + { + final TableChanges changes = event.tableChanges(); + final boolean anEditAddedATable = + event.origin() == ChangeOrigin.EDIT && !changes.addedKeys().isEmpty(); + final String tableKeyToSelect = anEditAddedATable ? changes.addedKeys().get(0) : null; + reconcile(changes, tableKeyToSelect); + } + + private void reconcile(final TableChanges changes, final String tableKeyToSelect) + { + if (control.isDisposed()) + { + return; + } + final DatasetModel model = datasetDocument.getModel(); + updateEditable(model); + + if (model.isBlank()) + { + tabs.reconcile(DatasetModel.EMPTY); + contentStackLayout.topControl = blankComposite; + } + else if (model.getTables().isEmpty()) + { + tabs.reconcile(DatasetModel.EMPTY); + contentStackLayout.topControl = noTablesComposite; + } + else + { + tabs.reconcile(model, changes, tableKeyToSelect); + contentStackLayout.topControl = tabFolder; + } + contentStack.layout(); + + updateBanner(model); + problemsSection.update(model); + actions.updateGridActionsEnablement(); + } + + /** + * Works out whether the page can be edited, which the input of the editor decides along with the model, + * and sets the buttons that depend on it. + */ + private void updateEditable(final DatasetModel model) + { + final boolean inputModifiable = editor.getSourceEditor().isEditorInputModifiable(); + editable = model.isEditable() && inputModifiable; + createEmptyDatasetButton.setEnabled(inputModifiable); + addTableButton.setEnabled(editable); + } + + /** + * Brings what depends on the surroundings of the editor up to date: the editable state, the banner, and + * the buttons follow the input of the editor, which can change with no change of the text, for example + * when the read-only attribute of its file is set or cleared, and the grids follow the theme of the + * workbench. + */ + void refreshExternalState() + { + if (control.isDisposed()) + { + return; + } + final DatasetModel model = datasetDocument.getModel(); + updateEditable(model); + updateBanner(model); + actions.updateGridActionsEnablement(); + tabs.updateThemes(); + } + + private void updateBanner(final DatasetModel model) + { + if (!editor.getSourceEditor().isEditorInputModifiable()) + { + errorBanner.showReadOnly(); + return; + } + for (final DatasetProblem problem : model.getProblems()) + { + if (problem.code().blocksEditing()) + { + errorBanner.showBlocking(problem); + return; + } + } + errorBanner.hide(); + } + + private void createEmptyDataset() + { + services.executeWithValidInput(datasetDocument::createEmptyDataset); + } + + private void runAddTableAction() + { + actions.runAddTableAction(); + } + +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageActions.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageActions.java new file mode 100644 index 00000000..5eebdb44 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageActions.java @@ -0,0 +1,405 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.BooleanSupplier; +import java.util.function.Supplier; + +import org.dbunit.eclipse.dataset.core.edit.DatasetDocument; +import org.dbunit.eclipse.dataset.ui.actions.AddColumnAction; +import org.dbunit.eclipse.dataset.ui.actions.AddTableAction; +import org.dbunit.eclipse.dataset.ui.actions.CopyAction; +import org.dbunit.eclipse.dataset.ui.actions.CutAction; +import org.dbunit.eclipse.dataset.ui.actions.DeleteAction; +import org.dbunit.eclipse.dataset.ui.actions.DeleteColumnAction; +import org.dbunit.eclipse.dataset.ui.actions.DeleteRowsAction; +import org.dbunit.eclipse.dataset.ui.actions.DeleteTableAction; +import org.dbunit.eclipse.dataset.ui.actions.DuplicateRowsAction; +import org.dbunit.eclipse.dataset.ui.actions.EditCellInDialogAction; +import org.dbunit.eclipse.dataset.ui.actions.FillDownAction; +import org.dbunit.eclipse.dataset.ui.actions.GridAction; +import org.dbunit.eclipse.dataset.ui.actions.InsertRowAboveAction; +import org.dbunit.eclipse.dataset.ui.actions.InsertRowBelowAction; +import org.dbunit.eclipse.dataset.ui.actions.MoveRowsDownAction; +import org.dbunit.eclipse.dataset.ui.actions.MoveRowsUpAction; +import org.dbunit.eclipse.dataset.ui.actions.PasteAction; +import org.dbunit.eclipse.dataset.ui.actions.RenameColumnAction; +import org.dbunit.eclipse.dataset.ui.actions.RenameTableAction; +import org.dbunit.eclipse.dataset.ui.actions.SelectAllAction; +import org.dbunit.eclipse.dataset.ui.actions.SetEmptyStringAction; +import org.dbunit.eclipse.dataset.ui.actions.SetNullAction; +import org.dbunit.eclipse.dataset.ui.actions.ShowInSourceAction; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.action.IMenuManager; +import org.eclipse.jface.action.MenuManager; +import org.eclipse.jface.action.ToolBarManager; +import org.eclipse.jface.commands.ActionHandler; +import org.eclipse.nebula.widgets.nattable.grid.GridRegion; +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.CTabFolder; +import org.eclipse.swt.custom.CTabItem; +import org.eclipse.swt.events.SelectionListener; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.widgets.ToolBar; +import org.eclipse.ui.actions.ActionFactory; +import org.eclipse.ui.contexts.IContextActivation; +import org.eclipse.ui.contexts.IContextService; +import org.eclipse.ui.handlers.IHandlerActivation; +import org.eclipse.ui.handlers.IHandlerService; +import org.eclipse.ui.services.IServiceLocator; + +/** + * The actions of the Tables page: the grid actions and the edit actions that the workbench retargets, with + * their toolbar, tab menu, and context menu items, their enablement for the current selection, undo and redo + * of the document's shared history, and the activation of the page's key binding context and command + * handlers while the page is active. All of it belongs to the page's controls, so it may be used on the UI + * thread only. + */ +final class TablesPageActions +{ + private static final String TABLES_PAGE_CONTEXT_ID = "org.dbunit.eclipse.dataset.ui.tablesPageContext"; + + private final Supplier serviceLocator; + + private final Supplier currentSelection; + + private final BooleanSupplier hasActiveCellEditor; + + private final SharedHistoryAction undoAction; + + private final SharedHistoryAction redoAction; + + private final InsertRowAboveAction insertRowAboveAction; + + private final InsertRowBelowAction insertRowBelowAction; + + private final DeleteRowsAction deleteRowsAction; + + private final DuplicateRowsAction duplicateRowsAction; + + private final MoveRowsUpAction moveRowsUpAction; + + private final MoveRowsDownAction moveRowsDownAction; + + private final AddColumnAction addColumnAction; + + private final RenameColumnAction renameColumnAction; + + private final DeleteColumnAction deleteColumnAction; + + private final AddTableAction addTableAction; + + private final RenameTableAction renameTableAction; + + private final DeleteTableAction deleteTableAction; + + private final SetNullAction setNullAction; + + private final SetEmptyStringAction setEmptyStringAction; + + private final FillDownAction fillDownAction; + + private final EditCellInDialogAction editCellInDialogAction; + + private final ShowInSourceAction showInSourceAction; + + private final CutAction cutAction; + + private final CopyAction copyAction; + + private final PasteAction pasteAction; + + private final DeleteAction deleteAction; + + private final SelectAllAction selectAllAction; + + private final Map globalActionHandlers; + + private final List gridActions = new ArrayList<>(); + + private final List retargetableActions = new ArrayList<>(); + + private final List handlerActivations = new ArrayList<>(); + + private final List handlers = new ArrayList<>(); + + private boolean active; + + private IContextActivation contextActivation; + + /** + * Creates the actions of a page and connects them to its tab folder. + * + * @param context The context that the actions work with. + * @param tabFolder The folder that gets the toolbar, the tab menu, and the selection listener. + * @param serviceLocator Returns the locator of the context service and the handler service of the + * editor. + * @param sourceUndoAction The Source page's undo action, which undo on this page runs. + * @param sourceRedoAction The Source page's redo action, which redo on this page runs. + * @param hasActiveCellEditor Returns true while a grid cell editor is open, so undo and redo decline to + * run and the key bindings of the grid commands leave its keys alone. + * @param currentSelection Returns the selection of the grid of the selected tab. + */ + TablesPageActions(final DatasetGridContext context, final CTabFolder tabFolder, + final Supplier serviceLocator, final IAction sourceUndoAction, + final IAction sourceRedoAction, final BooleanSupplier hasActiveCellEditor, + final Supplier currentSelection) + { + this.serviceLocator = serviceLocator; + this.currentSelection = currentSelection; + this.hasActiveCellEditor = hasActiveCellEditor; + + tabFolder.addSelectionListener( + SelectionListener.widgetSelectedAdapter(event -> updateGridActionsEnablement())); + + insertRowAboveAction = new InsertRowAboveAction(context); + insertRowBelowAction = new InsertRowBelowAction(context); + deleteRowsAction = new DeleteRowsAction(context); + duplicateRowsAction = new DuplicateRowsAction(context); + moveRowsUpAction = new MoveRowsUpAction(context); + moveRowsDownAction = new MoveRowsDownAction(context); + addColumnAction = new AddColumnAction(context); + renameColumnAction = new RenameColumnAction(context); + deleteColumnAction = new DeleteColumnAction(context); + addTableAction = new AddTableAction(context); + renameTableAction = new RenameTableAction(context); + deleteTableAction = new DeleteTableAction(context); + setNullAction = new SetNullAction(context); + setEmptyStringAction = new SetEmptyStringAction(context); + fillDownAction = new FillDownAction(context); + editCellInDialogAction = new EditCellInDialogAction(context); + showInSourceAction = new ShowInSourceAction(context); + cutAction = new CutAction(context); + copyAction = new CopyAction(context); + pasteAction = new PasteAction(context); + deleteAction = new DeleteAction(context); + selectAllAction = new SelectAllAction(context); + gridActions.add(insertRowAboveAction); + gridActions.add(insertRowBelowAction); + gridActions.add(deleteRowsAction); + gridActions.add(duplicateRowsAction); + gridActions.add(moveRowsUpAction); + gridActions.add(moveRowsDownAction); + gridActions.add(addColumnAction); + gridActions.add(renameColumnAction); + gridActions.add(deleteColumnAction); + gridActions.add(addTableAction); + gridActions.add(renameTableAction); + gridActions.add(deleteTableAction); + gridActions.add(setNullAction); + gridActions.add(setEmptyStringAction); + gridActions.add(fillDownAction); + gridActions.add(editCellInDialogAction); + gridActions.add(showInSourceAction); + retargetableActions.add(cutAction); + retargetableActions.add(copyAction); + retargetableActions.add(pasteAction); + retargetableActions.add(deleteAction); + retargetableActions.add(selectAllAction); + + final ToolBarManager toolBarManager = new ToolBarManager(SWT.FLAT); + toolBarManager.add(insertRowBelowAction); + toolBarManager.add(deleteRowsAction); + toolBarManager.add(addColumnAction); + toolBarManager.add(deleteColumnAction); + toolBarManager.add(addTableAction); + final ToolBar toolBar = toolBarManager.createControl(tabFolder); + tabFolder.setTopRight(toolBar); + + tabFolder.addMenuDetectListener(event -> + { + final Point point = tabFolder.toControl(event.x, event.y); + final CTabItem item = tabFolder.getItem(point); + if (item != null) + { + tabFolder.setSelection(item); + updateGridActionsEnablement(); + } + }); + final MenuManager tabMenuManager = new MenuManager(); + tabMenuManager.setRemoveAllWhenShown(true); + tabMenuManager.addMenuListener(manager -> + { + manager.add(addTableAction); + manager.add(renameTableAction); + manager.add(deleteTableAction); + }); + tabFolder.setMenu(tabMenuManager.createContextMenu(tabFolder)); + + final DatasetDocument datasetDocument = context.getDatasetDocument(); + undoAction = new SharedHistoryAction(sourceUndoAction, hasActiveCellEditor, datasetDocument::refresh); + redoAction = new SharedHistoryAction(sourceRedoAction, hasActiveCellEditor, datasetDocument::refresh); + globalActionHandlers = Map.of(ActionFactory.UNDO.getId(), undoAction, ActionFactory.REDO.getId(), + redoAction, ActionFactory.CUT.getId(), cutAction, ActionFactory.COPY.getId(), copyAction, + ActionFactory.PASTE.getId(), pasteAction, ActionFactory.DELETE.getId(), deleteAction, + ActionFactory.SELECT_ALL.getId(), selectAllAction); + } + + void activate() + { + active = true; + updateKeyBindingContext(); + final IHandlerService handlerService = serviceLocator.get().getService(IHandlerService.class); + for (final GridAction action : gridActions) + { + final ActionHandler handler = new ActionHandler(action); + handlers.add(handler); + handlerActivations.add(handlerService.activateHandler(action.getActionDefinitionId(), handler)); + } + updateGridActionsEnablement(); + } + + void deactivate() + { + active = false; + updateKeyBindingContext(); + releaseHandlers(); + } + + void dispose() + { + undoAction.dispose(); + redoAction.dispose(); + if (!handlers.isEmpty()) + { + releaseHandlers(); + } + } + + /** + * Deactivates the handlers of the grid commands and disposes them. The handler service never lets go + * of a handler that it was given, and a handler that is not disposed stays a listener of its action, + * so every activation of the page would otherwise add one that the action tells about each change. + */ + private void releaseHandlers() + { + final IHandlerService handlerService = serviceLocator.get().getService(IHandlerService.class); + handlerService.deactivateHandlers(handlerActivations); + handlerActivations.clear(); + for (final ActionHandler handler : handlers) + { + handler.dispose(); + } + handlers.clear(); + } + + void fillContextMenu(final IMenuManager menu, final String region) + { + if (GridRegion.BODY.equals(region)) + { + menu.add(cutAction); + menu.add(copyAction); + menu.add(pasteAction); + menu.add(insertRowAboveAction); + menu.add(insertRowBelowAction); + menu.add(duplicateRowsAction); + menu.add(deleteRowsAction); + menu.add(moveRowsUpAction); + menu.add(moveRowsDownAction); + menu.add(setNullAction); + menu.add(setEmptyStringAction); + menu.add(fillDownAction); + menu.add(editCellInDialogAction); + menu.add(showInSourceAction); + } + else if (GridRegion.ROW_HEADER.equals(region)) + { + menu.add(insertRowAboveAction); + menu.add(insertRowBelowAction); + menu.add(duplicateRowsAction); + menu.add(deleteRowsAction); + menu.add(moveRowsUpAction); + menu.add(moveRowsDownAction); + } + else if (GridRegion.COLUMN_HEADER.equals(region)) + { + menu.add(addColumnAction); + menu.add(renameColumnAction); + menu.add(deleteColumnAction); + } + } + + /** + * Refreshes the enablement of the grid actions from the current selection, and keeps the key bindings of + * the grid commands active only while no cell editor is open. + */ + void updateGridActionsEnablement() + { + updateKeyBindingContext(); + final GridSelection selection = currentSelection.get(); + for (final GridAction action : gridActions) + { + action.update(selection); + } + for (final GridAction action : retargetableActions) + { + action.update(selection); + } + } + + /** + * Activates the context of the grid's key bindings while the page is active and no cell editor is open. + * The key binding dispatcher consumes the key of a command that has an active handler even when the + * handler is disabled, so with the context active the keys of the open cell editor itself, such as + * Ctrl+Delete to delete a word, would never reach it. + */ + private void updateKeyBindingContext() + { + final boolean wanted = active && !hasActiveCellEditor.getAsBoolean(); + final boolean activated = contextActivation != null; + if (wanted == activated) + { + return; + } + final IContextService contextService = serviceLocator.get().getService(IContextService.class); + if (wanted) + { + contextActivation = contextService.activateContext(TABLES_PAGE_CONTEXT_ID); + } + else + { + contextService.deactivateContext(contextActivation); + contextActivation = null; + } + } + + /** + * Returns this page's action for a global action id, for {@link DatasetEditorContributor} to install + * while the Tables page is active. + * + * @param actionDefinitionId One of {@link ActionFactory}'s global action ids. + * @return The action, or null when this page has none for that id. + */ + IAction getGlobalActionHandler(final String actionDefinitionId) + { + return globalActionHandlers.get(actionDefinitionId); + } + + void runAddTableAction() + { + addTableAction.run(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/AnchorCellKeyEditAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/AnchorCellKeyEditAction.java new file mode 100644 index 00000000..a33e7e2e --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/AnchorCellKeyEditAction.java @@ -0,0 +1,67 @@ +/* + * + * 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.eclipse.dataset.ui.grid; + +import org.eclipse.nebula.widgets.nattable.NatTable; +import org.eclipse.nebula.widgets.nattable.coordinate.PositionCoordinate; +import org.eclipse.nebula.widgets.nattable.edit.command.EditSelectionCommand; +import org.eclipse.nebula.widgets.nattable.selection.SelectionLayer; +import org.eclipse.nebula.widgets.nattable.selection.command.SelectCellCommand; +import org.eclipse.nebula.widgets.nattable.ui.action.IKeyAction; +import org.eclipse.swt.events.KeyEvent; + +/** + * Reduces the selection to its anchor cell, then edits that cell: the typed character replaces the value + * when the key event carries a printable character, otherwise the existing value is kept. + * + * @since 1.0.0 + */ +final class AnchorCellKeyEditAction implements IKeyAction +{ + private final SelectionLayer selectionLayer; + + AnchorCellKeyEditAction(final SelectionLayer selectionLayer) + { + this.selectionLayer = selectionLayer; + } + + @Override + public void run(final NatTable natTable, final KeyEvent event) + { + final PositionCoordinate anchor = selectionLayer.getSelectionAnchor(); + if (anchor.columnPosition == SelectionLayer.NO_SELECTION + || anchor.rowPosition == SelectionLayer.NO_SELECTION) + { + return; + } + natTable.doCommand(new SelectCellCommand(selectionLayer, anchor.columnPosition, anchor.rowPosition, + false, false)); + if (PrintableCharacterKeyEventMatcher.isPrintable(event.character)) + { + natTable.doCommand( + new EditSelectionCommand(natTable, natTable.getConfigRegistry(), event.character)); + } + else + { + natTable.doCommand(new EditSelectionCommand(natTable, natTable.getConfigRegistry())); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/CellEditorAwareNatTable.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/CellEditorAwareNatTable.java new file mode 100644 index 00000000..eafb1c1e --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/CellEditorAwareNatTable.java @@ -0,0 +1,105 @@ +/* + * + * 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.eclipse.dataset.ui.grid; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.nebula.widgets.nattable.NatTable; +import org.eclipse.nebula.widgets.nattable.edit.CellEditorCreatedEvent; +import org.eclipse.nebula.widgets.nattable.layer.ILayer; +import org.eclipse.nebula.widgets.nattable.layer.event.ILayerEvent; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; + +/** + * A NatTable that tells its listeners when an in-place cell editor opens or closes. NatTable hands the event + * of a new editor to its layer listeners before it records the editor as its active one, and a dispose + * listener that such a layer listener adds to the editor's control runs before the one in which NatTable + * forgets the editor. A layer listener would therefore find {@link #getActiveCellEditor()} lagging behind. + * This table reports after it has updated its own state, so that a listener finds the active editor as it + * is. + * + * @since 1.0.0 + */ +final class CellEditorAwareNatTable extends NatTable +{ + private final List cellEditorListeners = new ArrayList<>(); + + private boolean disposing; + + /** + * Creates a table for a layer stack. + * + * @param parent The composite to create the table in. + * @param style The SWT style of the table. + * @param layer The top layer of the stack that the table shows. + * @param autoconfigure True to configure the table now; false when the caller configures it later. + */ + CellEditorAwareNatTable(final Composite parent, final int style, final ILayer layer, + final boolean autoconfigure) + { + super(parent, style, layer, autoconfigure); + addDisposeListener(event -> disposing = true); + } + + /** + * Notifies a listener whenever an in-place cell editor opens, and whenever one closes while the table + * stays; an editor that goes away with the table is not reported. + * + * @param listener The listener to notify; it is not told which editor changed. + */ + void addCellEditorListener(final Runnable listener) + { + cellEditorListeners.add(listener); + } + + @Override + public void handleLayerEvent(final ILayerEvent event) + { + super.handleLayerEvent(event); + if (event instanceof final CellEditorCreatedEvent created) + { + final Control editorControl = created.getEditor().getEditorControl(); + if (editorControl != null && !editorControl.isDisposed()) + { + editorControl.addDisposeListener(disposed -> editorClosed()); + notifyCellEditorListeners(); + } + } + } + + private void editorClosed() + { + if (!disposing) + { + notifyCellEditorListeners(); + } + } + + private void notifyCellEditorListeners() + { + for (final Runnable listener : cellEditorListeners) + { + listener.run(); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ColumnHeaderDataProvider.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ColumnHeaderDataProvider.java new file mode 100644 index 00000000..e7eae339 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ColumnHeaderDataProvider.java @@ -0,0 +1,64 @@ +/* + * + * 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.eclipse.dataset.ui.grid; + +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.eclipse.nebula.widgets.nattable.data.IDataProvider; + +/** + * Presents a table's column names as a single-row header data source. + * + * @since 1.0.0 + */ +final class ColumnHeaderDataProvider implements IDataProvider +{ + private final TableBodyDataProvider bodyDataProvider; + + ColumnHeaderDataProvider(final TableBodyDataProvider bodyDataProvider) + { + this.bodyDataProvider = bodyDataProvider; + } + + @Override + public int getColumnCount() + { + return bodyDataProvider.getColumnCount(); + } + + @Override + public int getRowCount() + { + return 1; + } + + @Override + public Object getDataValue(final int columnIndex, final int rowIndex) + { + final DatasetColumn column = bodyDataProvider.getColumn(columnIndex); + return column == null ? "" : column.name(); + } + + @Override + public void setDataValue(final int columnIndex, final int rowIndex, final Object newValue) + { + throw new UnsupportedOperationException("Column headers are not editable."); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ColumnHeaderLabels.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ColumnHeaderLabels.java new file mode 100644 index 00000000..488a7286 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ColumnHeaderLabels.java @@ -0,0 +1,78 @@ +/* + * + * 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.eclipse.dataset.ui.grid; + +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.dbunit.eclipse.dataset.core.model.DatasetModel; +import org.dbunit.eclipse.dataset.core.model.ProblemSeverity; +import org.eclipse.nebula.widgets.nattable.layer.LabelStack; +import org.eclipse.nebula.widgets.nattable.layer.cell.IConfigLabelAccumulator; + +/** + * Labels column header cells of a {@link DatasetGrid} for styling. + * + * @since 1.0.0 + */ +final class ColumnHeaderLabels implements IConfigLabelAccumulator +{ + static final String PENDING_COLUMN = "PENDING_COLUMN"; + + static final String COLUMN_WARNING = "COLUMN_WARNING"; + + static final String COLUMN_ERROR = "COLUMN_ERROR"; + + private final TableBodyDataProvider bodyDataProvider; + + ColumnHeaderLabels(final TableBodyDataProvider bodyDataProvider) + { + this.bodyDataProvider = bodyDataProvider; + } + + @Override + public void accumulateConfigLabels(final LabelStack configLabels, final int columnPosition, + final int rowPosition) + { + final DatasetColumn column = bodyDataProvider.getColumn(columnPosition); + if (column == null) + { + return; + } + if (column.pending()) + { + configLabels.addLabel(PENDING_COLUMN); + } + final ProblemSeverity worst = worstSeverityFor(column); + if (worst == ProblemSeverity.ERROR) + { + configLabels.addLabel(COLUMN_ERROR); + } + else if (worst == ProblemSeverity.WARNING) + { + configLabels.addLabel(COLUMN_WARNING); + } + } + + private ProblemSeverity worstSeverityFor(final DatasetColumn column) + { + final DatasetModel model = bodyDataProvider.getContext().getDatasetDocument().getModel(); + return model.getWorstSeverity(bodyDataProvider.getTableKey(), column.name()).orElse(null); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ColumnHeaderTooltip.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ColumnHeaderTooltip.java new file mode 100644 index 00000000..2d2bb447 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ColumnHeaderTooltip.java @@ -0,0 +1,120 @@ +/* + * + * 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.eclipse.dataset.ui.grid; + +import java.util.ArrayList; +import java.util.List; + +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.dbunit.eclipse.dataset.core.model.DatasetProblem; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.eclipse.jface.window.DefaultToolTip; +import org.eclipse.nebula.widgets.nattable.NatTable; +import org.eclipse.nebula.widgets.nattable.grid.GridRegion; +import org.eclipse.nebula.widgets.nattable.layer.LabelStack; +import org.eclipse.osgi.util.NLS; +import org.eclipse.swt.widgets.Event; + +/** + * Shows a column header's problems, its DTD default value, and why it has no values, when it is declared in + * the DTD without values or pending, as a tooltip. + * + * @since 1.0.0 + */ +final class ColumnHeaderTooltip extends DefaultToolTip +{ + private final NatTable natTable; + + private final TableBodyDataProvider bodyDataProvider; + + ColumnHeaderTooltip(final NatTable natTable, final TableBodyDataProvider bodyDataProvider) + { + super(natTable); + this.natTable = natTable; + this.bodyDataProvider = bodyDataProvider; + } + + @Override + protected boolean shouldCreateToolTip(final Event event) + { + return super.shouldCreateToolTip(event) && getText(event) != null; + } + + @Override + protected String getText(final Event event) + { + final LabelStack regionLabels = natTable.getRegionLabelsByXY(event.x, event.y); + if (regionLabels == null || !regionLabels.hasLabel(GridRegion.COLUMN_HEADER)) + { + return null; + } + return textForColumn(natTable.getColumnIndexByPosition(natTable.getColumnPositionByX(event.x))); + } + + /** + * Returns the tooltip text for a column header, by column index. + * + * @param columnIndex The column's index. + * @return The tooltip text, or null when the column has no problems and no default value, is not + * declared in the DTD without values, and is not pending. + */ + String textForColumn(final int columnIndex) + { + final DatasetColumn column = bodyDataProvider.getColumn(columnIndex); + if (column == null) + { + return null; + } + final List lines = problemMessages(column); + addColumnNotes(lines, column); + return lines.isEmpty() ? null : String.join("\n", lines); + } + + private static void addColumnNotes(final List lines, final DatasetColumn column) + { + if (column.hasDefaultValue()) + { + lines.add(NLS.bind(Messages.ColumnHeaderTooltip_defaultValue, column.defaultValue())); + } + else if (column.declared() && !column.hasValues()) + { + lines.add(Messages.ColumnHeaderTooltip_declaredWithoutValues); + } + if (column.pending()) + { + lines.add(Messages.ColumnHeaderTooltip_pending); + } + } + + private List problemMessages(final DatasetColumn column) + { + final List messages = new ArrayList<>(); + for (final DatasetProblem problem : bodyDataProvider.getContext().getDatasetDocument().getModel() + .getProblems(bodyDataProvider.getTableKey())) + { + if (column.name().equals(problem.columnName())) + { + messages.add(problem.message()); + } + } + return messages; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ColumnWidths.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ColumnWidths.java new file mode 100644 index 00000000..3030165b --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ColumnWidths.java @@ -0,0 +1,129 @@ +/* + * + * 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.eclipse.dataset.ui.grid; + +import java.util.HashMap; +import java.util.Map; + +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.eclipse.nebula.widgets.nattable.NatTable; +import org.eclipse.nebula.widgets.nattable.data.convert.IDisplayConverter; +import org.eclipse.nebula.widgets.nattable.layer.DataLayer; +import org.eclipse.swt.graphics.GC; + +/** + * Remembers each column's width by column key for the life of the editor, so a column keeps the width the + * user gave it across model refreshes. + *

+ * The widths are kept as the data layer stores them, unscaled. The layer displays them multiplied by the + * scaling factor of the display, so reading the displayed width back would apply the factor again at every + * refresh. + *

+ *

+ * A column that has no width yet gets one that fits its name and the text that the grid paints in its first + * rows. That text is the display text of each value, so a NULL cell counts as the NULL display text and a + * line break counts as the glyph that stands for it. + *

+ * + * @since 1.0.0 + */ +final class ColumnWidths +{ + private static final int MINIMUM_WIDTH = 48; + + private static final int MAXIMUM_WIDTH = 400; + + private static final int SAMPLE_ROW_LIMIT = 200; + + private final Map widthsByColumnKey = new HashMap<>(); + + private final IDisplayConverter displayConverter; + + /** + * Creates the widths of one grid. + * + * @param displayConverter Turns the value of a cell into the text that the grid paints in it, which is + * what the automatic widths are measured with. + */ + ColumnWidths(final IDisplayConverter displayConverter) + { + this.displayConverter = displayConverter; + } + + /** + * Records the current width of every column of a table that has been given one, so it survives a + * structural refresh even when the table is about to be replaced. + */ + void remember(final DatasetTable table, final DataLayer bodyDataLayer) + { + if (table == null) + { + return; + } + for (int index = 0; index < table.getColumns().size(); index++) + { + final int width = bodyDataLayer.getConfiguredColumnWidthByPosition(index); + if (width >= 0) + { + widthsByColumnKey.put(table.getColumns().get(index).key(), width); + } + } + } + + /** + * Applies each column's remembered width, or, the first time a column is seen, an automatically + * computed one. + */ + void applyTo(final DatasetTable table, final DataLayer bodyDataLayer, final NatTable natTable) + { + for (int index = 0; index < table.getColumns().size(); index++) + { + final String key = table.getColumns().get(index).key(); + final Integer remembered = widthsByColumnKey.get(key); + final int width = remembered != null ? remembered : computeAutoWidth(natTable, table, index); + bodyDataLayer.setColumnWidthByPosition(index, width); + widthsByColumnKey.put(key, width); + } + } + + private int computeAutoWidth(final NatTable natTable, final DatasetTable table, final int columnIndex) + { + final GC gc = new GC(natTable); + try + { + gc.setFont(natTable.getFont()); + int width = gc.textExtent(table.getColumns().get(columnIndex).name()).x + 24; + final int rowLimit = Math.min(table.getRows().size(), SAMPLE_ROW_LIMIT); + for (int row = 0; row < rowLimit; row++) + { + final String value = table.getEffectiveValue(row, columnIndex); + final Object displayValue = displayConverter.canonicalToDisplayValue(value); + final String text = String.valueOf(displayValue); + width = Math.max(width, gc.textExtent(text).x + 12); + } + return Math.max(MINIMUM_WIDTH, Math.min(MAXIMUM_WIDTH, width)); + } + finally + { + gc.dispose(); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ContextMenuTarget.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ContextMenuTarget.java new file mode 100644 index 00000000..cdc6fdfd --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ContextMenuTarget.java @@ -0,0 +1,126 @@ +/* + * + * 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.eclipse.dataset.ui.grid; + +import org.eclipse.nebula.widgets.nattable.NatTable; +import org.eclipse.nebula.widgets.nattable.grid.GridRegion; +import org.eclipse.nebula.widgets.nattable.layer.LabelStack; +import org.eclipse.nebula.widgets.nattable.layer.LayerUtil; +import org.eclipse.nebula.widgets.nattable.selection.SelectionLayer; +import org.eclipse.nebula.widgets.nattable.selection.command.SelectCellCommand; +import org.eclipse.nebula.widgets.nattable.selection.command.SelectColumnCommand; +import org.eclipse.nebula.widgets.nattable.selection.command.SelectRowsCommand; +import org.eclipse.nebula.widgets.nattable.ui.action.IMouseAction; +import org.eclipse.swt.events.MouseEvent; + +/** + * The part of a grid that a context menu is for. A right-click selects the cell, row, or column under the + * mouse unless it is already selected, so that the menu's commands act on it, as in a spreadsheet. + * + * @since 1.0.0 + */ +final class ContextMenuTarget implements IMouseAction +{ + private final SelectionLayer selectionLayer; + + ContextMenuTarget(final SelectionLayer selectionLayer) + { + this.selectionLayer = selectionLayer; + } + + /** + * Returns the grid region at a point of a grid. + * + * @param natTable The grid. + * @param x The point's x coordinate, relative to the grid. + * @param y The point's y coordinate, relative to the grid. + * @return The region that the point is in: {@link GridRegion#ROW_HEADER}, + * {@link GridRegion#COLUMN_HEADER}, or {@link GridRegion#CORNER} for a point in one of them, and + * {@link GridRegion#BODY} for any other point, including one beyond the last column or row. + */ + static String regionAt(final NatTable natTable, final int x, final int y) + { + final LabelStack regionLabels = natTable.getRegionLabelsByXY(x, y); + if (regionLabels == null) + { + return GridRegion.BODY; + } + if (regionLabels.hasLabel(GridRegion.ROW_HEADER)) + { + return GridRegion.ROW_HEADER; + } + if (regionLabels.hasLabel(GridRegion.COLUMN_HEADER)) + { + return GridRegion.COLUMN_HEADER; + } + if (regionLabels.hasLabel(GridRegion.CORNER)) + { + return GridRegion.CORNER; + } + return GridRegion.BODY; + } + + @Override + public void run(final NatTable natTable, final MouseEvent event) + { + final String region = regionAt(natTable, event.x, event.y); + final int columnPosition = natTable.getColumnPositionByX(event.x); + final int rowPosition = natTable.getRowPositionByY(event.y); + final int column = LayerUtil.convertColumnPosition(natTable, columnPosition, selectionLayer); + final int row = LayerUtil.convertRowPosition(natTable, rowPosition, selectionLayer); + if (GridRegion.ROW_HEADER.equals(region)) + { + selectRow(row); + } + else if (GridRegion.COLUMN_HEADER.equals(region)) + { + selectColumn(column); + } + else if (GridRegion.BODY.equals(region)) + { + selectCell(column, row); + } + } + + private void selectRow(final int row) + { + if (row >= 0 && !selectionLayer.isRowPositionFullySelected(row)) + { + selectionLayer.doCommand(new SelectRowsCommand(selectionLayer, 0, row, false, false)); + } + } + + private void selectColumn(final int column) + { + if (column >= 0 && !selectionLayer.isColumnPositionFullySelected(column)) + { + selectionLayer.doCommand(new SelectColumnCommand(selectionLayer, column, 0, false, false)); + } + } + + private void selectCell(final int column, final int row) + { + if (column >= 0 && row >= 0 && !selectionLayer.isCellPositionSelected(column, row)) + { + selectionLayer.doCommand(new SelectCellCommand(selectionLayer, column, row, false, false)); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/DatasetCellLabels.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/DatasetCellLabels.java new file mode 100644 index 00000000..53956bdc --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/DatasetCellLabels.java @@ -0,0 +1,100 @@ +/* + * + * 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.eclipse.dataset.ui.grid; + +import org.eclipse.nebula.widgets.nattable.layer.LabelStack; +import org.eclipse.nebula.widgets.nattable.layer.cell.IConfigLabelAccumulator; + +/** + * Labels body cells of a {@link DatasetGrid} for styling. + * + * @since 1.0.0 + */ +final class DatasetCellLabels implements IConfigLabelAccumulator +{ + static final String NULL_VALUE = "NULL_VALUE"; + + static final String DEFAULT_VALUE = "DEFAULT_VALUE"; + + static final String EDIT_IN_DIALOG = "EDIT_IN_DIALOG"; + + static final String MULTI_LINE_VALUE = "MULTI_LINE_VALUE"; + + private final TableBodyDataProvider bodyDataProvider; + + private int editInDialogColumnPosition = -1; + + private int editInDialogRowPosition = -1; + + DatasetCellLabels(final TableBodyDataProvider bodyDataProvider) + { + this.bodyDataProvider = bodyDataProvider; + } + + @Override + public void accumulateConfigLabels(final LabelStack configLabels, final int columnPosition, + final int rowPosition) + { + final Object value = bodyDataProvider.getDataValue(columnPosition, rowPosition); + if (value == null) + { + configLabels.addLabel(NULL_VALUE); + } + else if (isMultiLine(value.toString())) + { + configLabels.addLabel(MULTI_LINE_VALUE); + } + if (bodyDataProvider.isDefaultValue(columnPosition, rowPosition)) + { + configLabels.addLabel(DEFAULT_VALUE); + } + if (columnPosition == editInDialogColumnPosition && rowPosition == editInDialogRowPosition) + { + configLabels.addLabel(EDIT_IN_DIALOG); + } + } + + private static boolean isMultiLine(final String value) + { + return value.indexOf('\n') >= 0 || value.indexOf('\r') >= 0; + } + + /** + * Marks one cell to open in the multi-line dialog editor the next time it is edited. + * + * @param columnPosition The cell's column position. + * @param rowPosition The cell's row position. + */ + void forceEditInDialog(final int columnPosition, final int rowPosition) + { + editInDialogColumnPosition = columnPosition; + editInDialogRowPosition = rowPosition; + } + + /** + * Clears the cell marked by {@link #forceEditInDialog(int, int)}. + */ + void clearForceEditInDialog() + { + editInDialogColumnPosition = -1; + editInDialogRowPosition = -1; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGrid.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGrid.java new file mode 100644 index 00000000..a8ae76a6 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGrid.java @@ -0,0 +1,487 @@ +/* + * + * 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.eclipse.dataset.ui.grid; + +import java.util.ArrayList; +import java.util.List; + +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.eclipse.jface.action.MenuManager; +import org.eclipse.nebula.widgets.nattable.NatTable; +import org.eclipse.nebula.widgets.nattable.config.DefaultNatTableStyleConfiguration; +import org.eclipse.nebula.widgets.nattable.coordinate.PositionCoordinate; +import org.eclipse.nebula.widgets.nattable.data.IDataProvider; +import org.eclipse.nebula.widgets.nattable.edit.command.EditCellCommand; +import org.eclipse.nebula.widgets.nattable.edit.config.DefaultEditConfiguration; +import org.eclipse.nebula.widgets.nattable.edit.editor.ICellEditor; +import org.eclipse.nebula.widgets.nattable.grid.GridRegion; +import org.eclipse.nebula.widgets.nattable.grid.data.DefaultCornerDataProvider; +import org.eclipse.nebula.widgets.nattable.grid.data.DefaultRowHeaderDataProvider; +import org.eclipse.nebula.widgets.nattable.grid.layer.ColumnHeaderLayer; +import org.eclipse.nebula.widgets.nattable.grid.layer.CornerLayer; +import org.eclipse.nebula.widgets.nattable.grid.layer.DefaultColumnHeaderDataLayer; +import org.eclipse.nebula.widgets.nattable.grid.layer.DefaultRowHeaderDataLayer; +import org.eclipse.nebula.widgets.nattable.grid.layer.GridLayer; +import org.eclipse.nebula.widgets.nattable.grid.layer.RowHeaderLayer; +import org.eclipse.nebula.widgets.nattable.layer.DataLayer; +import org.eclipse.nebula.widgets.nattable.layer.LabelStack; +import org.eclipse.nebula.widgets.nattable.layer.cell.ILayerCell; +import org.eclipse.nebula.widgets.nattable.selection.SelectionLayer; +import org.eclipse.nebula.widgets.nattable.selection.command.SelectAllCommand; +import org.eclipse.nebula.widgets.nattable.selection.command.SelectRegionCommand; +import org.eclipse.nebula.widgets.nattable.selection.event.ISelectionEvent; +import org.eclipse.nebula.widgets.nattable.style.theme.DarkNatTableThemeConfiguration; +import org.eclipse.nebula.widgets.nattable.style.theme.ModernNatTableThemeConfiguration; +import org.eclipse.nebula.widgets.nattable.style.theme.ThemeConfiguration; +import org.eclipse.nebula.widgets.nattable.viewport.ViewportLayer; +import org.eclipse.nebula.widgets.nattable.viewport.command.ShowCellInViewportCommand; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.MenuDetectEvent; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; + +/** + * One NatTable showing one table of a {@link DatasetGridContext}'s dataset. + * + * @since 1.0.0 + */ +public final class DatasetGrid +{ + private final TableBodyDataProvider bodyDataProvider; + + private final DataLayer bodyDataLayer; + + private final SelectionLayer selectionLayer; + + private final CellEditorAwareNatTable natTable; + + private final DatasetCellLabels cellLabels; + + private final ColumnHeaderLabels columnHeaderLabels; + + private final ColumnHeaderTooltip columnHeaderTooltip; + + private final ColumnWidths columnWidths; + + private final DialogTextCellEditor dialogEditor = new DialogTextCellEditor(); + + private DatasetTable currentTable; + + private String contextMenuRegion = GridRegion.BODY; + + private boolean darkTheme; + + /** + * Assembles a NatTable for one table. + * + * @param parent The composite to create the NatTable in. + * @param context What the grid needs from the page that hosts it. + * @param tableKey The key of the table to display. + */ + public DatasetGrid(final Composite parent, final DatasetGridContext context, final String tableKey) + { + bodyDataProvider = new TableBodyDataProvider(context, tableKey); + final NullAwareDisplayConverter bodyDisplayConverter = + new NullAwareDisplayConverter(context::getNullDisplayText); + columnWidths = new ColumnWidths(bodyDisplayConverter); + bodyDataLayer = new DataLayer(bodyDataProvider); + cellLabels = new DatasetCellLabels(bodyDataProvider); + bodyDataLayer.setConfigLabelAccumulator(cellLabels); + selectionLayer = new SelectionLayer(bodyDataLayer); + final ViewportLayer viewportLayer = new ViewportLayer(selectionLayer); + + final IDataProvider columnHeaderDataProvider = new ColumnHeaderDataProvider(bodyDataProvider); + final DataLayer columnHeaderDataLayer = new DefaultColumnHeaderDataLayer(columnHeaderDataProvider); + columnHeaderLabels = new ColumnHeaderLabels(bodyDataProvider); + columnHeaderDataLayer.setConfigLabelAccumulator(columnHeaderLabels); + final ColumnHeaderLayer columnHeaderLayer = + new ColumnHeaderLayer(columnHeaderDataLayer, viewportLayer, selectionLayer); + + final IDataProvider rowHeaderDataProvider = new DefaultRowHeaderDataProvider(bodyDataProvider); + final DataLayer rowHeaderDataLayer = new DefaultRowHeaderDataLayer(rowHeaderDataProvider); + final RowHeaderLayer rowHeaderLayer = + new RowHeaderLayer(rowHeaderDataLayer, viewportLayer, selectionLayer); + + final IDataProvider cornerDataProvider = + new DefaultCornerDataProvider(columnHeaderDataProvider, rowHeaderDataProvider); + final CornerLayer cornerLayer = + new CornerLayer(new DataLayer(cornerDataProvider), rowHeaderLayer, columnHeaderLayer); + + // false: skip DefaultGridLayerConfiguration, whose DefaultEditBindings open an editor on a single + // click. + final GridLayer gridLayer = + new GridLayer(viewportLayer, columnHeaderLayer, rowHeaderLayer, cornerLayer, false); + gridLayer.addConfiguration(new DefaultEditConfiguration()); + + natTable = new CellEditorAwareNatTable(parent, NatTable.DEFAULT_STYLE_OPTIONS, gridLayer, false); + natTable.addConfiguration(new DefaultNatTableStyleConfiguration()); + natTable.addConfiguration(new GridEditConfiguration(context, dialogEditor, bodyDisplayConverter)); + natTable.addConfiguration(new SpreadsheetEditBindings(selectionLayer)); + natTable.addConfiguration(new GridStyleConfiguration()); + natTable.configure(); + darkTheme = context.isDarkTheme(); + natTable.setTheme(themeOf(darkTheme)); + natTable.addDisposeListener(event -> dialogEditor.cancelDialog()); + columnHeaderTooltip = new ColumnHeaderTooltip(natTable, bodyDataProvider); + wireContextMenu(context); + } + + private void wireContextMenu(final DatasetGridContext context) + { + final MenuManager menuManager = new MenuManager(); + menuManager.setRemoveAllWhenShown(true); + menuManager.addMenuListener(manager -> context.fillContextMenu(manager, contextMenuRegion)); + natTable.setMenu(menuManager.createContextMenu(natTable)); + natTable.addMenuDetectListener(this::recordContextMenuRegion); + } + + /** + * Records which part of the grid the menu that is about to open is for. A menu that the mouse opens is + * for the part under the mouse, where the request was made. A menu that the keyboard opens is for the + * cell that has the focus, whatever part the pointer happens to be over. + */ + private void recordContextMenuRegion(final MenuDetectEvent event) + { + if (event.detail == SWT.MENU_KEYBOARD) + { + contextMenuRegion = GridRegion.BODY; + return; + } + final Point requested = natTable.toControl(event.x, event.y); + contextMenuRegion = ContextMenuTarget.regionAt(natTable, requested.x, requested.y); + } + + /** + * Returns the control to place in the table's tab. + * + * @return The grid's control. + */ + public Control getControl() + { + return natTable; + } + + /** + * Commits the value of an open cell editor, if there is one, and closes it. + * + * @return True when no cell editor is open anymore; false when the value failed validation and the + * editor stays open. + */ + public boolean commitActiveCellEditor() + { + natTable.commitAndCloseActiveCellEditor(); + return natTable.getActiveCellEditor() == null; + } + + /** + * Closes an open cell editor, if there is one, without writing its value. + */ + public void cancelActiveCellEditor() + { + final ICellEditor cellEditor = natTable.getActiveCellEditor(); + if (cellEditor != null) + { + cellEditor.close(); + } + } + + NatTable getNatTable() + { + return natTable; + } + + TableBodyDataProvider getBodyDataProvider() + { + return bodyDataProvider; + } + + ColumnHeaderTooltip getColumnHeaderTooltip() + { + return columnHeaderTooltip; + } + + SelectionLayer getSelectionLayer() + { + return selectionLayer; + } + + /** + * Returns this grid's current selection. + * + * @return The selection snapshot. + */ + public GridSelection getSelection() + { + return GridSelection.compute(bodyDataProvider.getTableKey(), bodyDataProvider.getRowCount(), + bodyDataProvider.getColumnCount(), selectionLayer); + } + + /** + * Selects one cell, moves the selection anchor to it, and scrolls it into view. + * + * @param columnIndex The column to select. + * @param rowIndex The row to select. + */ + public void selectCell(final int columnIndex, final int rowIndex) + { + selectRegion(columnIndex, rowIndex, 1, 1); + } + + /** + * Notifies a listener whenever this grid's selection changes. + * + * @param listener The listener to notify; it is not told what changed. + */ + public void addSelectionListener(final Runnable listener) + { + selectionLayer.addLayerListener(event -> + { + if (event instanceof ISelectionEvent) + { + listener.run(); + } + }); + } + + /** + * Notifies a listener whenever a cell editor opens in this grid, and whenever one closes while the grid + * stays; an editor that goes away with the grid is not reported. When the listener runs, the grid reports + * the editor as it is, so the listener may ask whether a cell editor is active. + * + * @param listener The listener to notify; it is not told which editor changed. + */ + public void addCellEditorListener(final Runnable listener) + { + natTable.addCellEditorListener(listener); + } + + /** + * Returns the exact cells this grid has selected, which may not form a full rectangle. + * + * @return The selected cell positions, as {@code (columnIndex, rowIndex)} points. + */ + public List getSelectedCellPositions() + { + final List positions = new ArrayList<>(); + for (final PositionCoordinate position : selectionLayer.getSelectedCellPositions()) + { + positions.add(new Point(position.columnPosition, position.rowPosition)); + } + return positions; + } + + /** + * Selects every cell of this grid. + */ + public void selectAll() + { + natTable.doCommand(new SelectAllCommand()); + } + + /** + * Opens the anchor cell's value in a multi-line dialog editor. + */ + public void editCellInDialog() + { + final PositionCoordinate anchor = selectionLayer.getSelectionAnchor(); + if (anchor.columnPosition < 0 || anchor.rowPosition < 0) + { + return; + } + cellLabels.forceEditInDialog(anchor.columnPosition, anchor.rowPosition); + try + { + final ILayerCell cell = + selectionLayer.getCellByPosition(anchor.columnPosition, anchor.rowPosition); + natTable.doCommand(new EditCellCommand(natTable, natTable.getConfigRegistry(), cell)); + } + finally + { + cellLabels.clearForceEditInDialog(); + } + } + + LabelStack cellLabelsFor(final int columnIndex, final int rowIndex) + { + final LabelStack labels = new LabelStack(); + cellLabels.accumulateConfigLabels(labels, columnIndex, rowIndex); + return labels; + } + + LabelStack columnHeaderLabelsFor(final int columnIndex) + { + final LabelStack labels = new LabelStack(); + columnHeaderLabels.accumulateConfigLabels(labels, columnIndex, 0); + return labels; + } + + /** + * Applies the theme of the workbench again when it changed from light to dark, or back, since the grid + * took its theme, for example after the user switched the theme of the workbench in the preferences. + */ + public void updateTheme() + { + final boolean dark = bodyDataProvider.getContext().isDarkTheme(); + if (dark == darkTheme) + { + return; + } + darkTheme = dark; + natTable.setTheme(themeOf(dark)); + natTable.refresh(false); + } + + private static ThemeConfiguration themeOf(final boolean dark) + { + return dark ? new DarkNatTableThemeConfiguration() : new ModernNatTableThemeConfiguration(); + } + + /** + * Repaints every cell without changing the table or the selection, for example after the NULL display + * text changed. + */ + public void repaint() + { + natTable.refresh(false); + } + + /** + * Tells the grid its table was renamed, so it keeps showing the same table under its new key. + * + * @param newTableKey The table's key after the rename. + */ + public void tableRenamed(final String newTableKey) + { + bodyDataProvider.setTableKey(newTableKey); + } + + /** + * Selects a block of cells, clamped to the table, moves the selection anchor to the block's first cell, + * and scrolls that cell into view. + * + * @param firstColumnIndex The column of the block's first cell. + * @param firstRowIndex The row of the block's first cell. + * @param columnCount The number of columns to select. + * @param rowCount The number of rows to select. + */ + public void selectRegion(final int firstColumnIndex, final int firstRowIndex, final int columnCount, + final int rowCount) + { + final int tableColumnCount = bodyDataProvider.getColumnCount(); + final int tableRowCount = bodyDataProvider.getRowCount(); + if (tableColumnCount == 0 || tableRowCount == 0) + { + return; + } + final int column = clamp(firstColumnIndex, tableColumnCount); + final int row = clamp(firstRowIndex, tableRowCount); + final int width = Math.max(1, Math.min(columnCount, tableColumnCount - column)); + final int height = Math.max(1, Math.min(rowCount, tableRowCount - row)); + selectionLayer + .doCommand(new SelectRegionCommand(selectionLayer, column, row, width, height, false, false)); + natTable.doCommand(new ShowCellInViewportCommand(column, row)); + } + + private static int clamp(final int index, final int count) + { + return Math.max(0, Math.min(index, count - 1)); + } + + /** + * Refreshes the grid for a new snapshot of its table. + * + * @param newTable The table's current state. + */ + public void tableChanged(final DatasetTable newTable) + { + columnWidths.remember(currentTable, bodyDataLayer); + if (currentTable != null && sameStructure(currentTable, newTable)) + { + currentTable = newTable; + natTable.refresh(false); + return; + } + + cancelEditsOfTheOldStructure(); + + // Copy the anchor: the refresh clears the selection, which resets the layer's anchor object. + final PositionCoordinate oldAnchor = new PositionCoordinate(selectionLayer.getSelectionAnchor()); + currentTable = newTable; + natTable.refresh(true); + columnWidths.applyTo(newTable, bodyDataLayer, natTable); + reselect(oldAnchor, newTable); + } + + /** + * Cancels an open cell editor and an open edit dialog, because each commits to the position of the cell + * it was opened for, which another row or column may fill after the structure changed. + */ + private void cancelEditsOfTheOldStructure() + { + final boolean editing = natTable.getActiveCellEditor() != null || dialogEditor.isDialogOpen(); + if (editing) + { + cancelActiveCellEditor(); + dialogEditor.cancelDialog(); + final DatasetGridContext context = bodyDataProvider.getContext(); + context.setStatusMessage(Messages.DatasetGrid_editCancelledTableChanged); + } + } + + private void reselect(final PositionCoordinate oldAnchor, final DatasetTable newTable) + { + final int rowCount = newTable.getRows().size(); + final int columnCount = newTable.getColumns().size(); + if (rowCount == 0 || columnCount == 0 || oldAnchor.columnPosition < 0 || oldAnchor.rowPosition < 0) + { + return; + } + final int column = Math.min(oldAnchor.columnPosition, columnCount - 1); + final int row = Math.min(oldAnchor.rowPosition, rowCount - 1); + selectionLayer.setSelectedCell(column, row); + selectionLayer.moveSelectionAnchor(column, row); + } + + private static boolean sameStructure(final DatasetTable oldTable, final DatasetTable newTable) + { + if (oldTable.getRows().size() != newTable.getRows().size()) + { + return false; + } + final List oldColumns = oldTable.getColumns(); + final List newColumns = newTable.getColumns(); + if (oldColumns.size() != newColumns.size()) + { + return false; + } + for (int index = 0; index < oldColumns.size(); index++) + { + if (!oldColumns.get(index).key().equals(newColumns.get(index).key())) + { + return false; + } + } + return true; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridContext.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridContext.java new file mode 100644 index 00000000..82e63d0d --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridContext.java @@ -0,0 +1,185 @@ +/* + * + * 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.eclipse.dataset.ui.grid; + +import java.util.List; + +import org.dbunit.eclipse.dataset.core.edit.DatasetDocument; +import org.eclipse.jface.action.IMenuManager; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; + +/** + * What a {@link DatasetGrid} needs from the page that hosts it. + * + * @since 1.0.0 + */ +public interface DatasetGridContext +{ + /** + * Returns the dataset the grid displays. + * + * @return The dataset document. + */ + DatasetDocument getDatasetDocument(); + + /** + * Returns whether the grid may be edited. + * + * @return True when the source has no errors that block editing and the input is modifiable. + */ + boolean isEditable(); + + /** + * Returns the text shown in place of a NULL cell's value. + * + * @return The configured NULL display text. + */ + String getNullDisplayText(); + + /** + * Returns whether the editor is showing in a dark color theme. + * + * @return True when the relative luminance of the editor's background is below 0.5. + */ + boolean isDarkTheme(); + + /** + * Runs an edit, reporting a rejected edit through the editor's status line. + * + * @param edit The edit to run. + * @return True when the edit ran; false when the page is not editable, the editor input could not be + * validated, or the edit was rejected. + */ + boolean executeEdit(Runnable edit); + + /** + * Runs a multi-cell edit (paste, fill down, delete rows), reporting a rejected edit through a modal + * error dialog instead of just the status line, since the user expects a larger effect. + * + * @param title The dialog's title, naming the command. + * @param edit The edit to run. + * @return True when the edit ran; false when the page is not editable, the editor input could not be + * validated, or the edit was rejected. + */ + boolean executeMultiCellEdit(String title, Runnable edit); + + /** + * Adds this page's actions for a grid region to a context menu. + * + * @param menu The menu to add to. + * @param region One of NatTable's {@code GridRegion} constants naming where the menu was requested. + */ + void fillContextMenu(IMenuManager menu, String region); + + /** + * Returns whether a grid cell editor is currently open. + * + * @return True while a cell editor is active, so row, column, and table actions must do nothing. + */ + boolean hasActiveCellEditor(); + + /** + * Returns the active grid's current selection. + * + * @return The selection snapshot, or {@link GridSelection#NONE} when there is no active grid. + */ + GridSelection getSelection(); + + /** + * Selects a block of cells of the active grid, clamped to its table, moves the selection anchor to the + * block's first cell, and scrolls that cell into view. An operation calls it after its edit, which has + * then already refreshed the grid. + * + * @param firstColumnIndex The column of the block's first cell. + * @param firstRowIndex The row of the block's first cell. + * @param columnCount The number of columns to select. + * @param rowCount The number of rows to select. + */ + void selectRegion(int firstColumnIndex, int firstRowIndex, int columnCount, int rowCount); + + /** + * Returns the shell to parent dialogs on. + * + * @return The page's shell. + */ + Shell getShell(); + + /** + * Returns the active cell editor's text control. + * + * @return The control, or null when no cell editor is active or its editor is not backed by a + * {@code Text} control. + */ + Text getActiveCellEditorText(); + + /** + * Returns the exact cells the active grid has selected, which may not form a full rectangle. + * + * @return The selected cell positions, as {@code (columnIndex, rowIndex)} points. + */ + List getSelectedCellPositions(); + + /** + * Selects every cell of the active grid. + */ + void selectAll(); + + /** + * Opens the anchor cell's value in a multi-line dialog editor. + */ + void editCellInDialog(); + + /** + * Selects and reveals the anchor cell's range on the Source page, and switches to it. + */ + void showInSource(); + + /** + * Shows an informational message on the editor's status line. + * + * @param message The message to show, or null to clear it. + */ + void setStatusMessage(String message); + + /** + * Shows an error message on the editor's status line, where it takes priority over a message set with + * {@link #setStatusMessage}, and beeps, as for a rejected edit. + * + * @param message The error message to show. + */ + void setStatusErrorMessage(String message); + + /** + * Writes text to the system clipboard. + * + * @param text The text to write. + */ + void writeClipboardText(String text); + + /** + * Reads text from the system clipboard. + * + * @return The clipboard's text, or null when it holds no text. + */ + String readClipboardText(); +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/DialogTextCellEditor.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/DialogTextCellEditor.java new file mode 100644 index 00000000..14e8fe7f --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/DialogTextCellEditor.java @@ -0,0 +1,79 @@ +/* + * + * 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.eclipse.dataset.ui.grid; + +import org.eclipse.nebula.widgets.nattable.edit.editor.MultiLineTextCellEditor; +import org.eclipse.nebula.widgets.nattable.widget.EditModeEnum; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Shell; + +/** + * The multi-line text editor that a {@link DatasetGrid} opens in a dialog. NatTable does not report a dialog's + * editor as the active cell editor, so this editor remembers the dialog's shell, which lets the grid close the + * dialog without a value when the table changes under it. + * + * @since 1.0.0 + */ +final class DialogTextCellEditor extends MultiLineTextCellEditor +{ + private Shell dialogShell; + + DialogTextCellEditor() + { + super(false); + } + + @Override + protected Control activateCell(final Composite parent, final Object originalCanonicalValue) + { + final Control control = super.activateCell(parent, originalCanonicalValue); + if (editMode == EditModeEnum.DIALOG) + { + dialogShell = parent.getShell(); + } + return control; + } + + /** + * Tells whether this editor is open in a dialog. + * + * @return True while the dialog that this editor was last activated in is showing. + */ + boolean isDialogOpen() + { + return dialogShell != null && !dialogShell.isDisposed(); + } + + /** + * Closes the dialog that this editor is open in, as the dialog's Cancel button does, so that the dialog's + * value is not written. Does nothing when no dialog is open. + */ + void cancelDialog() + { + final Shell shell = dialogShell; + if (shell != null && !shell.isDisposed()) + { + close(); + shell.close(); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/EditDisplayConverter.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/EditDisplayConverter.java new file mode 100644 index 00000000..b94fad1a --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/EditDisplayConverter.java @@ -0,0 +1,44 @@ +/* + * + * 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.eclipse.dataset.ui.grid; + +import org.eclipse.nebula.widgets.nattable.data.convert.DisplayConverter; + +/** + * Converts a cell's canonical value to its editable text for {@code DisplayMode.EDIT}: NULL becomes the + * empty string, otherwise the value is unchanged; the empty string is never turned back into NULL. + * + * @since 1.0.0 + */ +final class EditDisplayConverter extends DisplayConverter +{ + @Override + public Object canonicalToDisplayValue(final Object canonicalValue) + { + return canonicalValue == null ? "" : canonicalValue; + } + + @Override + public Object displayToCanonicalValue(final Object displayValue) + { + return displayValue; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/GridEditConfiguration.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/GridEditConfiguration.java new file mode 100644 index 00000000..21f44096 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/GridEditConfiguration.java @@ -0,0 +1,92 @@ +/* + * + * 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.eclipse.dataset.ui.grid; + +import org.eclipse.nebula.widgets.nattable.config.AbstractRegistryConfiguration; +import org.eclipse.nebula.widgets.nattable.config.CellConfigAttributes; +import org.eclipse.nebula.widgets.nattable.config.EditableRule; +import org.eclipse.nebula.widgets.nattable.config.IConfigRegistry; +import org.eclipse.nebula.widgets.nattable.data.convert.IDisplayConverter; +import org.eclipse.nebula.widgets.nattable.edit.EditConfigAttributes; +import org.eclipse.nebula.widgets.nattable.edit.config.DialogErrorHandling; +import org.eclipse.nebula.widgets.nattable.edit.editor.TextCellEditor; +import org.eclipse.nebula.widgets.nattable.grid.GridRegion; +import org.eclipse.nebula.widgets.nattable.style.DisplayMode; + +/** + * Registers how a {@link DatasetGrid}'s cells are displayed and edited. + * + * @since 1.0.0 + */ +final class GridEditConfiguration extends AbstractRegistryConfiguration +{ + private final DatasetGridContext context; + + private final DialogTextCellEditor dialogEditor; + + private final IDisplayConverter bodyDisplayConverter; + + GridEditConfiguration(final DatasetGridContext context, final DialogTextCellEditor dialogEditor, + final IDisplayConverter bodyDisplayConverter) + { + this.context = context; + this.dialogEditor = dialogEditor; + this.bodyDisplayConverter = bodyDisplayConverter; + } + + @Override + public void configureRegistry(final IConfigRegistry configRegistry) + { + configRegistry.registerConfigAttribute(EditConfigAttributes.CELL_EDITABLE_RULE, new EditableRule() + { + @Override + public boolean isEditable(final int columnIndex, final int rowIndex) + { + return context.isEditable(); + } + }); + configRegistry.registerConfigAttribute(EditConfigAttributes.CELL_EDITOR, + new TextCellEditor(true, true), DisplayMode.EDIT); + configRegistry.registerConfigAttribute(EditConfigAttributes.CELL_EDITOR, dialogEditor, + DisplayMode.EDIT, DatasetCellLabels.MULTI_LINE_VALUE); + configRegistry.registerConfigAttribute(EditConfigAttributes.OPEN_IN_DIALOG, Boolean.TRUE, + DisplayMode.EDIT, DatasetCellLabels.MULTI_LINE_VALUE); + configRegistry.registerConfigAttribute(EditConfigAttributes.CELL_EDITOR, dialogEditor, + DisplayMode.EDIT, DatasetCellLabels.EDIT_IN_DIALOG); + configRegistry.registerConfigAttribute(EditConfigAttributes.OPEN_IN_DIALOG, Boolean.TRUE, + DisplayMode.EDIT, DatasetCellLabels.EDIT_IN_DIALOG); + configRegistry.registerConfigAttribute(EditConfigAttributes.DATA_VALIDATOR, + new XmlCharacterValidator(), DisplayMode.EDIT); + configRegistry.registerConfigAttribute(EditConfigAttributes.VALIDATION_ERROR_HANDLER, + new DialogErrorHandling()); + configRegistry.registerConfigAttribute(EditConfigAttributes.OPEN_ADJACENT_EDITOR, Boolean.FALSE); + // Without this, NatTable edits the next cell after Enter or Tab commits and moves the selection. + configRegistry.registerConfigAttribute(EditConfigAttributes.ACTIVATE_EDITOR_ON_TRAVERSAL, Boolean.FALSE); + configRegistry.registerConfigAttribute(EditConfigAttributes.SUPPORT_MULTI_EDIT, Boolean.FALSE); + // Only body cells hold dataset values; the headers and the corner keep NatTable's default converter. + configRegistry.registerConfigAttribute(CellConfigAttributes.DISPLAY_CONVERTER, bodyDisplayConverter, + DisplayMode.NORMAL, GridRegion.BODY); + // Without a region: Edit Cell in Dialog edits a selection layer cell, which carries no region label, + // and NatTable's default converter would turn its empty string into NULL. + configRegistry.registerConfigAttribute(CellConfigAttributes.DISPLAY_CONVERTER, + new EditDisplayConverter(), DisplayMode.EDIT); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/GridSelection.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/GridSelection.java new file mode 100644 index 00000000..db62dd4f --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/GridSelection.java @@ -0,0 +1,188 @@ +/* + * + * 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.eclipse.dataset.ui.grid; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.eclipse.nebula.widgets.nattable.coordinate.PositionCoordinate; +import org.eclipse.nebula.widgets.nattable.coordinate.Range; +import org.eclipse.nebula.widgets.nattable.selection.SelectionLayer; + +/** + * A snapshot of a grid's selection, computed once per selection or model change and shared by every + * {@code GridAction} so none of them has to call {@code SelectionLayer.getSelectedCellPositions()}, whose + * cost grows with the number of selected cells. + * + * @param tableKey The key of the table the selection belongs to, or null when there is no active grid. + * @param rowCount The table's current row count. + * @param columnCount The table's current column count. + * @param anchorColumnIndex The selection anchor's column index, or {@code SelectionLayer.NO_SELECTION}. + * @param anchorRowIndex The selection anchor's row index, or {@code SelectionLayer.NO_SELECTION}. + * @param rowIndexes The selected row indexes, sorted ascending. + * @param columnIndexes The selected column indexes, sorted ascending. + * @param wholeRowsSelected True when every selected row is selected in full, and at least one row is + * selected. + * @since 1.0.0 + */ +public record GridSelection(String tableKey, int rowCount, int columnCount, int anchorColumnIndex, + int anchorRowIndex, List rowIndexes, List columnIndexes, boolean wholeRowsSelected) +{ + /** + * The selection of a page with no active grid: no table, no anchor, no selected rows or columns. + */ + public static final GridSelection NONE = new GridSelection(null, 0, 0, SelectionLayer.NO_SELECTION, + SelectionLayer.NO_SELECTION, List.of(), List.of(), false); + + /** + * Creates a snapshot with its own unmodifiable copies of the selected row and column indexes, so that + * neither the caller that computed them nor a caller of the accessors can change the snapshot. + */ + public GridSelection + { + rowIndexes = List.copyOf(rowIndexes); + columnIndexes = List.copyOf(columnIndexes); + } + + /** + * Computes a selection snapshot from a grid's current selection. + * + * @param tableKey The key of the table the grid displays. + * @param rowCount The table's current row count. + * @param columnCount The table's current column count. + * @param selectionLayer The layer to read the selection from. + * @return The computed snapshot. + */ + public static GridSelection compute(final String tableKey, final int rowCount, final int columnCount, + final SelectionLayer selectionLayer) + { + final PositionCoordinate anchor = selectionLayer.getSelectionAnchor(); + final List rowIndexes = new ArrayList<>(); + for (final Range range : selectionLayer.getSelectedRowPositions()) + { + for (int rowIndex = range.start; rowIndex < range.end; rowIndex++) + { + rowIndexes.add(rowIndex); + } + } + Collections.sort(rowIndexes); + final List columnIndexes = new ArrayList<>(); + for (final int columnIndex : selectionLayer.getSelectedColumnPositions()) + { + columnIndexes.add(columnIndex); + } + Collections.sort(columnIndexes); + boolean wholeRowsSelected = !rowIndexes.isEmpty(); + for (final int rowIndex : rowIndexes) + { + if (!selectionLayer.isRowPositionFullySelected(rowIndex)) + { + wholeRowsSelected = false; + break; + } + } + return new GridSelection(tableKey, rowCount, columnCount, anchor.columnPosition, anchor.rowPosition, + rowIndexes, columnIndexes, wholeRowsSelected); + } + + /** + * Returns the lowest selected row index. + * + * @return The lowest selected row index, or -1 when no row is selected. + */ + public int firstRowIndex() + { + return boundStart(rowIndexes); + } + + /** + * Returns the highest selected row index. + * + * @return The highest selected row index, or -1 when no row is selected. + */ + public int lastRowIndex() + { + return boundEnd(rowIndexes); + } + + /** + * Returns the lowest selected column index. + * + * @return The lowest selected column index, or -1 when no column is selected. + */ + public int firstColumnIndex() + { + return boundStart(columnIndexes); + } + + /** + * Returns the highest selected column index. + * + * @return The highest selected column index, or -1 when no column is selected. + */ + public int lastColumnIndex() + { + return boundEnd(columnIndexes); + } + + /** + * Returns whether the selected rows form one contiguous block, with no gaps. + * + * @return True when at least one row is selected and the selected row indexes have no gaps. + */ + public boolean isContiguousRowSelection() + { + return !rowIndexes.isEmpty() && lastRowIndex() - firstRowIndex() + 1 == rowIndexes.size(); + } + + /** + * Returns whether more than one cell is selected. A selected row holds a selected cell, and so does a + * selected column, so this is so exactly when the selection spans several rows or several columns, which + * the snapshot knows without a list of the selected cells. + * + * @return True when at least two cells are selected. + */ + public boolean hasMultipleCells() + { + return rowIndexes.size() > 1 || columnIndexes.size() > 1; + } + + /** + * Returns the number of columns from the first selected column to the last. + * + * @return The width of the selection's bounding rectangle, or 0 when no column is selected. + */ + public int columnSpan() + { + return columnIndexes.isEmpty() ? 0 : lastColumnIndex() - firstColumnIndex() + 1; + } + + private static int boundStart(final List indexes) + { + return indexes.isEmpty() ? -1 : indexes.get(0); + } + + private static int boundEnd(final List indexes) + { + return indexes.isEmpty() ? -1 : indexes.get(indexes.size() - 1); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/GridStyleConfiguration.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/GridStyleConfiguration.java new file mode 100644 index 00000000..4c69996a --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/GridStyleConfiguration.java @@ -0,0 +1,93 @@ +/* + * + * 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.eclipse.dataset.ui.grid; + +import org.eclipse.jface.preference.JFacePreferences; +import org.eclipse.jface.resource.JFaceResources; +import org.eclipse.nebula.widgets.nattable.config.AbstractRegistryConfiguration; +import org.eclipse.nebula.widgets.nattable.config.CellConfigAttributes; +import org.eclipse.nebula.widgets.nattable.config.IConfigRegistry; +import org.eclipse.nebula.widgets.nattable.painter.cell.ICellPainter; +import org.eclipse.nebula.widgets.nattable.style.CellStyleAttributes; +import org.eclipse.nebula.widgets.nattable.style.DisplayMode; +import org.eclipse.nebula.widgets.nattable.style.Style; +import org.eclipse.swt.graphics.Image; +import org.eclipse.ui.ISharedImages; +import org.eclipse.ui.PlatformUI; + +/** + * Registers how a {@link DatasetGrid} styles NULL values, default values, pending columns, and columns with + * problems. + * + * @since 1.0.0 + */ +final class GridStyleConfiguration extends AbstractRegistryConfiguration +{ + @Override + public void configureRegistry(final IConfigRegistry configRegistry) + { + final Style nullValueStyle = italicStyle(); + nullValueStyle.setAttributeValue(CellStyleAttributes.FOREGROUND_COLOR, + JFaceResources.getColorRegistry().get(JFacePreferences.QUALIFIER_COLOR)); + registerForNormalAndSelect(configRegistry, nullValueStyle, DatasetCellLabels.NULL_VALUE); + + registerForNormalAndSelect(configRegistry, italicStyle(), DatasetCellLabels.DEFAULT_VALUE); + + registerForNormalAndSelect(configRegistry, italicStyle(), ColumnHeaderLabels.PENDING_COLUMN); + + registerProblemPainter(configRegistry, ColumnHeaderLabels.COLUMN_WARNING, + ISharedImages.IMG_OBJS_WARN_TSK); + registerProblemPainter(configRegistry, ColumnHeaderLabels.COLUMN_ERROR, + ISharedImages.IMG_OBJS_ERROR_TSK); + } + + private static Style italicStyle() + { + final Style style = new Style(); + style.setAttributeValue(CellStyleAttributes.FONT, + JFaceResources.getFontRegistry().getItalic(JFaceResources.DEFAULT_FONT)); + return style; + } + + private static void registerForNormalAndSelect(final IConfigRegistry configRegistry, final Style style, + final String label) + { + configRegistry.registerConfigAttribute(CellConfigAttributes.CELL_STYLE, style, DisplayMode.NORMAL, + label); + configRegistry.registerConfigAttribute(CellConfigAttributes.CELL_STYLE, style, DisplayMode.SELECT, + label); + } + + private static void registerProblemPainter(final IConfigRegistry configRegistry, final String label, + final String sharedImageKey) + { + final ICellPainter painter = new ProblemHeaderPainter(sharedImage(sharedImageKey)); + configRegistry.registerConfigAttribute(CellConfigAttributes.CELL_PAINTER, painter, DisplayMode.NORMAL, + label); + configRegistry.registerConfigAttribute(CellConfigAttributes.CELL_PAINTER, painter, DisplayMode.SELECT, + label); + } + + private static Image sharedImage(final String key) + { + return PlatformUI.getWorkbench().getSharedImages().getImage(key); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/NullAwareDisplayConverter.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/NullAwareDisplayConverter.java new file mode 100644 index 00000000..8aa0f7a6 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/NullAwareDisplayConverter.java @@ -0,0 +1,66 @@ +/* + * + * 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.eclipse.dataset.ui.grid; + +import java.util.function.Supplier; + +import org.eclipse.nebula.widgets.nattable.data.convert.DisplayConverter; + +/** + * Converts a cell's canonical value to its display text for {@code DisplayMode.NORMAL}: NULL becomes the + * configured NULL display text, and each line break becomes {@code ⏎}. + * + * @since 1.0.0 + */ +final class NullAwareDisplayConverter extends DisplayConverter +{ + private static final String LINE_BREAK_DISPLAY = "⏎"; + + private final Supplier nullDisplayText; + + /** + * Creates the converter of one grid. + * + * @param nullDisplayText Supplies the text to show for NULL each time a value is converted, so that a + * change of the configured text shows when the grid repaints. + */ + NullAwareDisplayConverter(final Supplier nullDisplayText) + { + this.nullDisplayText = nullDisplayText; + } + + @Override + public Object canonicalToDisplayValue(final Object canonicalValue) + { + if (canonicalValue == null) + { + return nullDisplayText.get(); + } + return canonicalValue.toString().replace("\r\n", LINE_BREAK_DISPLAY).replace("\r", LINE_BREAK_DISPLAY) + .replace("\n", LINE_BREAK_DISPLAY); + } + + @Override + public Object displayToCanonicalValue(final Object displayValue) + { + return displayValue; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/PrintableCharacterKeyEventMatcher.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/PrintableCharacterKeyEventMatcher.java new file mode 100644 index 00000000..ed17b755 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/PrintableCharacterKeyEventMatcher.java @@ -0,0 +1,81 @@ +/* + * + * 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.eclipse.dataset.ui.grid; + +import org.eclipse.nebula.widgets.nattable.ui.matcher.IKeyEventMatcher; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.KeyEvent; + +/** + * Matches a key event that types a character to start editing the selection anchor: a non-zero, + * non-control character, which is not one half of a supplementary character, typed with no modifier, + * Shift, AltGr (Ctrl+Alt, or Command+Option on macOS), or, on macOS only, Option alone, since a character + * typed with Option there arrives with only {@link SWT#ALT} in the state mask. + * + * @since 1.0.0 + */ +final class PrintableCharacterKeyEventMatcher implements IKeyEventMatcher +{ + private static final int ALT_GR = SWT.MOD1 | SWT.MOD3; + + private final boolean mac; + + /** + * Creates the matcher. + * + * @param mac True to accept Option alone instead of AltGr, matching macOS; false for every other + * platform. + */ + PrintableCharacterKeyEventMatcher(final boolean mac) + { + this.mac = mac; + } + + /** + * Tells whether the character of a key event is one that can start an edit when it is typed: not + * nothing, not a control character, and not one half of a supplementary character such as an emoji. + * SWT sends each half as a key event of its own, so an editor that is started by one of them would hold + * a character that is not well formed. + * + * @param character The character of the key event. + * @return True when typing the character can start an edit that is seeded with it. + */ + static boolean isPrintable(final char character) + { + return character != 0 && !Character.isISOControl(character) && !Character.isSurrogate(character); + } + + @Override + public boolean matches(final KeyEvent event) + { + if (!isPrintable(event.character)) + { + return false; + } + final int modifiers = event.stateMask & SWT.MODIFIER_MASK; + if (modifiers == SWT.NONE || modifiers == SWT.SHIFT) + { + return true; + } + return mac ? modifiers == SWT.ALT || modifiers == (SWT.ALT | SWT.SHIFT) + : modifiers == ALT_GR || modifiers == (ALT_GR | SWT.SHIFT); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ProblemHeaderPainter.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ProblemHeaderPainter.java new file mode 100644 index 00000000..d34fb5e9 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ProblemHeaderPainter.java @@ -0,0 +1,93 @@ +/* + * + * 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.eclipse.dataset.ui.grid; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.nebula.widgets.nattable.config.CellConfigAttributes; +import org.eclipse.nebula.widgets.nattable.config.IConfigRegistry; +import org.eclipse.nebula.widgets.nattable.layer.cell.ILayerCell; +import org.eclipse.nebula.widgets.nattable.painter.cell.AbstractCellPainter; +import org.eclipse.nebula.widgets.nattable.painter.cell.ICellPainter; +import org.eclipse.nebula.widgets.nattable.painter.cell.ImagePainter; +import org.eclipse.nebula.widgets.nattable.painter.cell.TextPainter; +import org.eclipse.nebula.widgets.nattable.painter.cell.decorator.CellPainterDecorator; +import org.eclipse.nebula.widgets.nattable.ui.util.CellEdgeEnum; +import org.eclipse.swt.graphics.GC; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.Rectangle; + +/** + * Paints the header of a column with problems as the grid's theme paints a header in the cell's display + * mode, with a warning or error image over its right edge, so that the header keeps the theme's colors + * and the image stays while the column is selected. + * + * @since 1.0.0 + */ +final class ProblemHeaderPainter extends AbstractCellPainter +{ + private final ICellPainter imagePainter; + + ProblemHeaderPainter(final Image image) + { + imagePainter = new ImagePainter(image, false); + } + + @Override + public void paintCell(final ILayerCell cell, final GC gc, final Rectangle bounds, + final IConfigRegistry configRegistry) + { + final ICellPainter decorated = new CellPainterDecorator(themePainter(cell, configRegistry), + CellEdgeEnum.RIGHT, 0, imagePainter, false, false); + decorated.paintCell(cell, gc, bounds, configRegistry); + } + + @Override + public int getPreferredWidth(final ILayerCell cell, final GC gc, final IConfigRegistry configRegistry) + { + final ICellPainter themePainter = themePainter(cell, configRegistry); + final int textWidth = themePainter.getPreferredWidth(cell, gc, configRegistry); + return textWidth + imagePainter.getPreferredWidth(cell, gc, configRegistry); + } + + @Override + public int getPreferredHeight(final ILayerCell cell, final GC gc, final IConfigRegistry configRegistry) + { + final ICellPainter themePainter = themePainter(cell, configRegistry); + final int textHeight = themePainter.getPreferredHeight(cell, gc, configRegistry); + return Math.max(textHeight, imagePainter.getPreferredHeight(cell, gc, configRegistry)); + } + + /** + * Returns the painter that the grid's configuration, its theme included, gives the cell without its + * problem labels. + */ + private static ICellPainter themePainter(final ILayerCell cell, final IConfigRegistry configRegistry) + { + final List labels = new ArrayList<>(cell.getConfigLabels().getLabels()); + labels.remove(ColumnHeaderLabels.COLUMN_WARNING); + labels.remove(ColumnHeaderLabels.COLUMN_ERROR); + final ICellPainter painter = configRegistry.getConfigAttribute(CellConfigAttributes.CELL_PAINTER, + cell.getDisplayMode(), labels); + return painter == null ? new TextPainter() : painter; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/SpreadsheetEditBindings.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/SpreadsheetEditBindings.java new file mode 100644 index 00000000..cd3c3389 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/SpreadsheetEditBindings.java @@ -0,0 +1,67 @@ +/* + * + * 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.eclipse.dataset.ui.grid; + +import org.eclipse.jface.util.Util; +import org.eclipse.nebula.widgets.nattable.config.AbstractUiBindingConfiguration; +import org.eclipse.nebula.widgets.nattable.edit.action.MouseEditAction; +import org.eclipse.nebula.widgets.nattable.grid.GridRegion; +import org.eclipse.nebula.widgets.nattable.selection.SelectionLayer; +import org.eclipse.nebula.widgets.nattable.ui.binding.UiBindingRegistry; +import org.eclipse.nebula.widgets.nattable.ui.matcher.CellEditorMouseEventMatcher; +import org.eclipse.nebula.widgets.nattable.ui.matcher.KeyEventMatcher; +import org.eclipse.nebula.widgets.nattable.ui.matcher.MouseEventMatcher; +import org.eclipse.swt.SWT; + +/** + * Replaces NatTable's default single-click editing with spreadsheet-style editing: F2 or a typed character + * edits the selection anchor, and a double-click edits the clicked cell. A right-click selects the cell, + * row, or column it hits, unless it is already selected, before the context menu opens. + * + * @since 1.0.0 + */ +final class SpreadsheetEditBindings extends AbstractUiBindingConfiguration +{ + private final SelectionLayer selectionLayer; + + SpreadsheetEditBindings(final SelectionLayer selectionLayer) + { + this.selectionLayer = selectionLayer; + } + + @Override + public void configureUiBindings(final UiBindingRegistry uiBindingRegistry) + { + final AnchorCellKeyEditAction editAnchorCell = new AnchorCellKeyEditAction(selectionLayer); + uiBindingRegistry.registerKeyBinding(new KeyEventMatcher(SWT.NONE, SWT.F2), editAnchorCell); + uiBindingRegistry.registerKeyBinding(new PrintableCharacterKeyEventMatcher(Util.isMac()), + editAnchorCell); + uiBindingRegistry.registerDoubleClickBinding(new CellEditorMouseEventMatcher(GridRegion.BODY), + new MouseEditAction()); + final ContextMenuTarget contextMenuTarget = new ContextMenuTarget(selectionLayer); + uiBindingRegistry.registerMouseDownBinding(MouseEventMatcher.bodyRightClick(SWT.NONE), + contextMenuTarget); + uiBindingRegistry.registerMouseDownBinding(MouseEventMatcher.rowHeaderRightClick(SWT.NONE), + contextMenuTarget); + uiBindingRegistry.registerMouseDownBinding(MouseEventMatcher.columnHeaderRightClick(SWT.NONE), + contextMenuTarget); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/TableBodyDataProvider.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/TableBodyDataProvider.java new file mode 100644 index 00000000..c3e60983 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/TableBodyDataProvider.java @@ -0,0 +1,173 @@ +/* + * + * 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.eclipse.dataset.ui.grid; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.dbunit.eclipse.dataset.core.edit.CellChange; +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.eclipse.nebula.widgets.nattable.data.IDataProvider; + +/** + * Presents one table's rows and columns of a {@link DatasetGridContext}'s current model as grid data. + * + * @since 1.0.0 + */ +final class TableBodyDataProvider implements IDataProvider +{ + private final DatasetGridContext context; + + private String tableKey; + + TableBodyDataProvider(final DatasetGridContext context, final String tableKey) + { + this.context = context; + this.tableKey = tableKey; + } + + @Override + public int getRowCount() + { + return table().map(table -> table.getRows().size()).orElse(0); + } + + @Override + public int getColumnCount() + { + return table().map(table -> table.getColumns().size()).orElse(0); + } + + /** + * Returns the value dbUnit loads for a cell, which is its column's default value when its row has no + * attribute for the column. + */ + @Override + public Object getDataValue(final int columnIndex, final int rowIndex) + { + final Optional table = table(); + if (table.isEmpty() || !isCell(table.get(), columnIndex, rowIndex)) + { + return null; + } + return table.get().getEffectiveValue(rowIndex, columnIndex); + } + + /** + * Returns whether a cell shows its column's default value because its row has no attribute for the + * column. + * + * @param columnIndex The index of the cell's column. + * @param rowIndex The index of the cell's row. + * @return True when the cell's value is the DTD's default, not a value written in the row. + */ + boolean isDefaultValue(final int columnIndex, final int rowIndex) + { + final Optional table = table(); + if (table.isEmpty() || !isCell(table.get(), columnIndex, rowIndex)) + { + return false; + } + final String value = table.get().getRows().get(rowIndex).getValue(columnIndex); + return value == null && table.get().getColumns().get(columnIndex).hasDefaultValue(); + } + + private static boolean isCell(final DatasetTable table, final int columnIndex, final int rowIndex) + { + return rowIndex >= 0 && rowIndex < table.getRows().size() && columnIndex >= 0 + && columnIndex < table.getColumns().size(); + } + + @Override + public void setDataValue(final int columnIndex, final int rowIndex, final Object newValue) + { + final Optional table = table(); + if (table.isEmpty()) + { + return; + } + if (!isCell(table.get(), columnIndex, rowIndex)) + { + context.setStatusErrorMessage(Messages.TableBodyDataProvider_cellNoLongerInTable); + return; + } + final Object currentValue = getDataValue(columnIndex, rowIndex); + if (currentValue == null && "".equals(newValue)) + { + return; + } + if (isDefaultValue(columnIndex, rowIndex) && Objects.equals(newValue, currentValue)) + { + return; + } + final String columnName = table.get().getColumns().get(columnIndex).name(); + final String key = tableKey; + final String value = keepLineBreaks((String) newValue, (String) currentValue); + context.executeEdit(() -> context.getDatasetDocument().setCells(key, + List.of(new CellChange(rowIndex, columnName, value)))); + } + + /** + * Returns an edited value with the CR LF line breaks that a multi-line text widget writes on some + * platforms turned back into line feeds, unless the value already used CR LF before the edit. + */ + private static String keepLineBreaks(final String editedValue, final String currentValue) + { + if (editedValue == null || (currentValue != null && currentValue.contains("\r\n"))) + { + return editedValue; + } + return editedValue.replace("\r\n", "\n"); + } + + void setTableKey(final String tableKey) + { + this.tableKey = tableKey; + } + + DatasetGridContext getContext() + { + return context; + } + + String getTableKey() + { + return tableKey; + } + + DatasetColumn getColumn(final int columnIndex) + { + final Optional table = table(); + if (table.isEmpty() || columnIndex < 0 || columnIndex >= table.get().getColumns().size()) + { + return null; + } + return table.get().getColumns().get(columnIndex); + } + + private Optional table() + { + return context.getDatasetDocument().getModel().findTable(tableKey); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/XmlCharacterValidator.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/XmlCharacterValidator.java new file mode 100644 index 00000000..1fec96c1 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/XmlCharacterValidator.java @@ -0,0 +1,53 @@ +/* + * + * 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.eclipse.dataset.ui.grid; + +import org.dbunit.eclipse.dataset.core.flatxml.AttributeValueCodec; +import org.eclipse.nebula.widgets.nattable.data.validate.DataValidator; +import org.eclipse.nebula.widgets.nattable.data.validate.ValidationFailedException; + +/** + * Rejects an edited value that contains a code point that is not an XML 1.0 {@code Char}. + * + * @since 1.0.0 + */ +final class XmlCharacterValidator extends DataValidator +{ + @Override + public boolean validate(final int columnIndex, final int rowIndex, final Object newValue) + { + if (newValue == null) + { + return true; + } + final String text = newValue.toString(); + for (int offset = 0; offset < text.length();) + { + final int codePoint = text.codePointAt(offset); + if (!AttributeValueCodec.isXmlChar(codePoint)) + { + throw new ValidationFailedException(AttributeValueCodec.notXmlCharacterMessage(codePoint)); + } + offset += Character.charCount(codePoint); + } + return true; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/messages.properties b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/messages.properties new file mode 100644 index 00000000..6d524c62 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/messages.properties @@ -0,0 +1,79 @@ +Editor_tablesPageText = Tables +Editor_sourcePageText = Source +PreferencePage_description = Match column sensing and table name case to how your tests load flat XML datasets, so that the editor groups tables and reports problems as dbUnit does. +PreferencePage_nullDisplayText = Text shown for &NULL cells: +PreferencePage_nullDisplayTextBlank = The NULL display text must not be blank, so that NULL cells look different from empty strings. +PreferencePage_assumeColumnSensing = Validate as if the tests enable dbUnit &column sensing +PreferencePage_caseSensitiveTableNames = Treat &table names as case-sensitive, like dbUnit's caseSensitiveTableNames +NewDatasetWizard_windowTitle = New dbUnit Flat XML Dataset +NewDatasetWizard_pageTitle = dbUnit Flat XML Dataset +NewDatasetWizard_pageDescription = Create a dbUnit flat XML dataset file that starts with an empty dataset. +NewDatasetWizard_openErrorTitle = Cannot Open New Dataset +Action_insertRowAbove = Insert Row Above +Action_insertRowBelow = Insert Row Below +Action_duplicateRows = Duplicate Rows +Action_deleteRows = Delete Rows +Action_moveRowsUp = Move Rows Up +Action_moveRowsDown = Move Rows Down +Action_addColumn = Add Column... +Action_renameColumn = Rename Column... +Action_deleteColumn = Delete Column +Action_addTable = Add Table... +Action_renameTable = Rename Table... +Action_deleteTable = Delete Table +Action_setNull = Set to NULL +Action_setEmptyString = Set to Empty String +Action_fillDown = Fill Down +Action_editCellInDialog = Edit Cell in Dialog... +Action_showInSource = Show in Source +Action_cut = Cut +Action_copy = Copy +Action_paste = Paste +Action_delete = Delete +Action_selectAll = Select All +ColumnDialog_addTitle = Add Column +ColumnDialog_renameTitle = Rename Column +ColumnDialog_name = Column name: +ColumnDialog_nameWithDtdWarning = Column name:\n\ndbUnit reads a flat XML dataset's columns from its DTD; add this column there too, or it will have no effect. +ColumnDialog_renameNameWithDtdWarning = Column name:\n\ndbUnit reads a flat XML dataset's columns from its DTD; change it there too, or the DTD will no longer match. +DeleteColumnDialog_message = Delete column ''{0}''? This removes {1} values. +DeleteColumnDialog_messageOneValue = Delete column ''{0}''? This removes 1 value. +DeleteColumnDialog_dtdNote = dbUnit reads columns from the DTD, so the DTD must be updated too. +DeleteTableDialog_message = Delete table ''{0}''? This removes {1} rows. +DeleteTableDialog_messageOneRow = Delete table ''{0}''? This removes 1 row. +DeleteTableDialog_dtdNote = dbUnit reads tables from the DTD, so the DTD must be updated too. +TableDialog_addTitle = Add Table +TableDialog_renameTitle = Rename Table +TableDialog_name = Table name: +TableDialog_nameWithDtdWarning = Table name:\n\ndbUnit reads a flat XML dataset's tables from its DTD; this table exists only there, so change the DTD instead of renaming it here. +TableDialog_renameNameWithDtdWarning = Table name:\n\ndbUnit reads a flat XML dataset's tables from its DTD; a DTD file declares this table and the editor does not change DTD files, so rename the table there too, or dbUnit fails to load the dataset. +TableDialog_columnNames = Column names (comma-separated, optional): +NameValidator_empty = Enter a name. +NameValidator_invalid = ''{0}'' is not a valid XML name. +NameValidator_used = ''{0}'' is already used. +TableNameValidator_reserved = ''{0}'' is reserved for the root element. +Paste_ignoredColumn = Ignored {0} pasted column beyond the table''s last column. +Paste_ignoredColumns = Ignored {0} pasted columns beyond the table''s last column. +Paste_skippedRow = Skipped {0} pasted row without values, because a new row needs at least one value. +Paste_skippedRows = Skipped {0} pasted rows without values, because a new row needs at least one value. +Copy_selectionNotAligned = Cannot copy or cut this selection, because its rows have different selected columns. Select the same columns in every selected row. +ErrorBanner_showInSource =
Show in Source +ErrorBanner_readOnly = This dataset is read-only. +ProblemsSection_header = Problems ({0}) +TablesPage_emptyFile = The file is empty. +TablesPage_createEmptyDataset = Create Empty Dataset +TablesPage_noTables = The dataset has no tables. +TablesPage_tableTooltip = {0}, {1} +TablesPage_tableTooltipOneRow = 1 row +TablesPage_tableTooltipRows = {0} rows +TablesPage_tableTooltipOneColumn = 1 column +TablesPage_tableTooltipColumns = {0} columns +TablesPage_declaredOnlyTableTooltip = Declared in the DTD; no rows +ColumnHeaderTooltip_declaredWithoutValues = Declared in the DTD; no values yet +ColumnHeaderTooltip_defaultValue = The DTD gives this column the default value "{0}", which dbUnit loads for a row without a value. +ColumnHeaderTooltip_pending = This column has no values yet; it is saved when at least one row has a value. +SetNull_defaultValue = Column "{0}" has the DTD default value "{1}", so dbUnit loads it, not NULL, for a cell without a value. +SetNull_defaultValues = Columns {0} have DTD default values, so dbUnit loads them, not NULL, for cells without a value. +InsertRow_newFirstRow = The new row is the table's first row now, so it has an empty string in each column of the old first row: dbUnit takes a table's columns from its first row. +TableBodyDataProvider_cellNoLongerInTable = The cell is no longer in the table, so its new value was not saved. +DatasetGrid_editCancelledTableChanged = The table changed while a cell was being edited, so the edit was cancelled. diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/preferences/DatasetPreferenceInitializer.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/preferences/DatasetPreferenceInitializer.java new file mode 100644 index 00000000..191fa7eb --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/preferences/DatasetPreferenceInitializer.java @@ -0,0 +1,42 @@ +/* + * + * 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.eclipse.dataset.ui.preferences; + +import org.dbunit.eclipse.dataset.ui.DatasetUiPlugin; +import org.eclipse.core.runtime.preferences.AbstractPreferenceInitializer; +import org.eclipse.jface.preference.IPreferenceStore; + +/** + * Sets the default values of the dataset editor's preferences. + * + * @since 1.0.0 + */ +public final class DatasetPreferenceInitializer extends AbstractPreferenceInitializer +{ + @Override + public void initializeDefaultPreferences() + { + final IPreferenceStore store = DatasetUiPlugin.getDefault().getPreferenceStore(); + store.setDefault(PreferenceKeys.NULL_DISPLAY_TEXT, "(null)"); + store.setDefault(PreferenceKeys.ASSUME_COLUMN_SENSING, false); + store.setDefault(PreferenceKeys.CASE_SENSITIVE_TABLE_NAMES, false); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/preferences/DatasetPreferencePage.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/preferences/DatasetPreferencePage.java new file mode 100644 index 00000000..cc764583 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/preferences/DatasetPreferencePage.java @@ -0,0 +1,79 @@ +/* + * + * 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.eclipse.dataset.ui.preferences; + +import org.dbunit.eclipse.dataset.ui.DatasetUiPlugin; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.eclipse.jface.preference.BooleanFieldEditor; +import org.eclipse.jface.preference.FieldEditorPreferencePage; +import org.eclipse.jface.preference.StringFieldEditor; +import org.eclipse.ui.IWorkbench; +import org.eclipse.ui.IWorkbenchPreferencePage; + +/** + * The dbUnit Dataset Editor page of the workbench preferences: the NULL display text and the dataset + * options that decide how the editor groups tables and validates them. + * + * @since 1.0.0 + */ +public final class DatasetPreferencePage extends FieldEditorPreferencePage implements IWorkbenchPreferencePage +{ + /** + * The identifier this page is registered under in {@code plugin.xml}. + */ + public static final String ID = "org.dbunit.eclipse.dataset.ui.preferencePage"; + + /** + * Creates the page over the dataset editor's preference store. + */ + public DatasetPreferencePage() + { + super(GRID); + setPreferenceStore(DatasetUiPlugin.getDefault().getPreferenceStore()); + setDescription(Messages.PreferencePage_description); + } + + /** + * Does nothing; the page needs nothing from the workbench. + * + * @param workbench The workbench. + */ + @Override + public void init(final IWorkbench workbench) + { + // Nothing to initialize. + } + + @Override + protected void createFieldEditors() + { + final StringFieldEditor nullDisplayTextEditor = new StringFieldEditor( + PreferenceKeys.NULL_DISPLAY_TEXT, Messages.PreferencePage_nullDisplayText, + getFieldEditorParent()); + nullDisplayTextEditor.setEmptyStringAllowed(false); + nullDisplayTextEditor.setErrorMessage(Messages.PreferencePage_nullDisplayTextBlank); + addField(nullDisplayTextEditor); + addField(new BooleanFieldEditor(PreferenceKeys.ASSUME_COLUMN_SENSING, + Messages.PreferencePage_assumeColumnSensing, getFieldEditorParent())); + addField(new BooleanFieldEditor(PreferenceKeys.CASE_SENSITIVE_TABLE_NAMES, + Messages.PreferencePage_caseSensitiveTableNames, getFieldEditorParent())); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/preferences/PreferenceKeys.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/preferences/PreferenceKeys.java new file mode 100644 index 00000000..d6caea80 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/preferences/PreferenceKeys.java @@ -0,0 +1,66 @@ +/* + * + * 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.eclipse.dataset.ui.preferences; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlOptions; +import org.dbunit.eclipse.dataset.ui.DatasetUiPlugin; +import org.eclipse.jface.preference.IPreferenceStore; + +/** + * The keys of the dataset editor's preferences, stored in the UI bundle's instance-scope preference + * store. + * + * @since 1.0.0 + */ +public final class PreferenceKeys +{ + /** + * The text shown for a NULL cell. + */ + public static final String NULL_DISPLAY_TEXT = "nullDisplayText"; + + /** + * Whether to validate as if the tests enable dbUnit column sensing. + */ + public static final String ASSUME_COLUMN_SENSING = "assumeColumnSensing"; + + /** + * Whether to group tables case-sensitively, as with dbUnit's {@code caseSensitiveTableNames}. + */ + public static final String CASE_SENSITIVE_TABLE_NAMES = "caseSensitiveTableNames"; + + private PreferenceKeys() + { + } + + /** + * Builds the flat XML options that the current preference values describe. + * + * @return A {@link FlatXmlOptions} matching the current {@link #CASE_SENSITIVE_TABLE_NAMES} and + * {@link #ASSUME_COLUMN_SENSING} preference values. + */ + public static FlatXmlOptions readOptions() + { + final IPreferenceStore store = DatasetUiPlugin.getDefault().getPreferenceStore(); + return new FlatXmlOptions(store.getBoolean(CASE_SENSITIVE_TABLE_NAMES), + store.getBoolean(ASSUME_COLUMN_SENSING)); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/WholeTagDamagerRepairer.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/WholeTagDamagerRepairer.java new file mode 100644 index 00000000..f7c42647 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/WholeTagDamagerRepairer.java @@ -0,0 +1,62 @@ +/* + * + * 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.eclipse.dataset.ui.source; + +import org.eclipse.jface.text.DocumentEvent; +import org.eclipse.jface.text.IRegion; +import org.eclipse.jface.text.ITypedRegion; +import org.eclipse.jface.text.rules.DefaultDamagerRepairer; +import org.eclipse.jface.text.rules.ITokenScanner; + +/** + * Colors a tag again as a whole whenever a part of it changes. The inherited repairer colors only the lines + * that changed, and a scan that starts on a later line of a tag cannot know that a quoted attribute value + * began on an earlier line, so it would take the text before the closing quote for the rest of the tag. + * + * @since 1.0.0 + */ +final class WholeTagDamagerRepairer extends DefaultDamagerRepairer +{ + /** + * Creates the damager and repairer. + * + * @param scanner The scanner that finds the colored tokens of a tag. + */ + WholeTagDamagerRepairer(final ITokenScanner scanner) + { + super(scanner); + } + + /** + * Returns the whole tag as the region to color again. + * + * @param partition The tag that changed. + * @param event The change of the document. + * @param documentPartitioningChanged Whether the change altered the partitioning. + * @return The partition. + */ + @Override + public IRegion getDamageRegion(final ITypedRegion partition, final DocumentEvent event, + final boolean documentPartitioningChanged) + { + return partition; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/XmlDocumentSetupParticipant.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/XmlDocumentSetupParticipant.java new file mode 100644 index 00000000..343bc12d --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/XmlDocumentSetupParticipant.java @@ -0,0 +1,60 @@ +/* + * + * 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.eclipse.dataset.ui.source; + +import org.eclipse.core.filebuffers.IDocumentSetupParticipant; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.IDocumentExtension3; +import org.eclipse.jface.text.IDocumentPartitioner; +import org.eclipse.jface.text.rules.FastPartitioner; + +/** + * Installs the XML partitioning on each document that the file buffer manager creates for a flat XML + * dataset file, whichever editor opens the file. + * + * @since 1.0.0 + */ +public final class XmlDocumentSetupParticipant implements IDocumentSetupParticipant +{ + /** + * The name of the partitioning that divides a dataset document into comments, processing + * instructions, the DOCTYPE, and tags. + */ + public static final String PARTITIONING = "org.dbunit.eclipse.dataset.ui.xmlPartitioning"; + + /** + * Installs a partitioner for {@link #PARTITIONING} on the document. + * + * @param document The document to set up. + */ + @Override + public void setup(final IDocument document) + { + if (document instanceof final IDocumentExtension3 extension) + { + final XmlPartitionScanner scanner = new XmlPartitionScanner(); + final String[] partitionTypes = XmlPartitionScanner.PARTITION_TYPES.toArray(String[]::new); + final IDocumentPartitioner partitioner = new FastPartitioner(scanner, partitionTypes); + extension.setDocumentPartitioner(PARTITIONING, partitioner); + partitioner.connect(document); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/XmlPartitionScanner.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/XmlPartitionScanner.java new file mode 100644 index 00000000..596ff0b9 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/XmlPartitionScanner.java @@ -0,0 +1,157 @@ +/* + * + * 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.eclipse.dataset.ui.source; + +import java.util.List; + +import org.eclipse.jface.text.rules.ICharacterScanner; +import org.eclipse.jface.text.rules.IPredicateRule; +import org.eclipse.jface.text.rules.IToken; +import org.eclipse.jface.text.rules.MultiLineRule; +import org.eclipse.jface.text.rules.RuleBasedPartitionScanner; +import org.eclipse.jface.text.rules.Token; + +/** + * Divides XML text into comment, processing instruction, DOCTYPE, and tag partitions; everything else, + * such as the whitespace between tags, stays in the default partition. + * + * @since 1.0.0 + */ +final class XmlPartitionScanner extends RuleBasedPartitionScanner +{ + /** + * The content type of a comment, from {@code }. + */ + static final String COMMENT = "__xml_comment"; + + /** + * The content type of a processing instruction, such as the XML declaration. + */ + static final String PROCESSING_INSTRUCTION = "__xml_processing_instruction"; + + /** + * The content type of the DOCTYPE, which ends at its first {@code >}, so a DOCTYPE with an internal + * subset ends early. + */ + static final String DOCTYPE = "__xml_doctype"; + + /** + * The content type of a start, end, or empty-element tag. + */ + static final String TAG = "__xml_tag"; + + /** + * The content types of the partitions this scanner finds, without the default content type. + */ + static final List PARTITION_TYPES = List.of(COMMENT, PROCESSING_INSTRUCTION, DOCTYPE, TAG); + + XmlPartitionScanner() + { + setPredicateRules(new IPredicateRule[] { + new MultiLineRule("", new Token(COMMENT), (char) 0, true), + new MultiLineRule("", new Token(PROCESSING_INSTRUCTION), (char) 0, true), + new MultiLineRule("", new Token(DOCTYPE), (char) 0, true), + new TagRule(new Token(TAG)) }); + } + + /** + * Matches a tag from its {@code <} to the first {@code >} outside a quoted attribute value. A tag + * without its {@code >} extends to the end of the scanned range. + */ + private static final class TagRule implements IPredicateRule + { + private final IToken successToken; + + TagRule(final IToken successToken) + { + this.successToken = successToken; + } + + @Override + public IToken getSuccessToken() + { + return successToken; + } + + @Override + public IToken evaluate(final ICharacterScanner scanner) + { + return evaluate(scanner, false); + } + + @Override + public IToken evaluate(final ICharacterScanner scanner, final boolean resume) + { + IToken token = successToken; + if (resume || readOpeningBracket(scanner)) + { + skipToTagEnd(scanner); + } + else + { + token = Token.UNDEFINED; + } + return token; + } + + private static boolean readOpeningBracket(final ICharacterScanner scanner) + { + final boolean opening = scanner.read() == '<'; + if (!opening) + { + scanner.unread(); + } + return opening; + } + + private static void skipToTagEnd(final ICharacterScanner scanner) + { + int quote = 0; + int c = scanner.read(); + while (c != ICharacterScanner.EOF && !(quote == 0 && c == '>')) + { + quote = quoteAfter(quote, c); + c = scanner.read(); + } + if (c == ICharacterScanner.EOF) + { + scanner.unread(); + } + } + + private static int quoteAfter(final int quote, final int c) + { + int after = quote; + if (quote == 0) + { + if (c == '"' || c == '\'') + { + after = c; + } + } + else if (c == quote) + { + after = 0; + } + return after; + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/XmlSourceViewerConfiguration.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/XmlSourceViewerConfiguration.java new file mode 100644 index 00000000..ea5eee74 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/XmlSourceViewerConfiguration.java @@ -0,0 +1,129 @@ +/* + * + * 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.eclipse.dataset.ui.source; + +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.presentation.IPresentationReconciler; +import org.eclipse.jface.text.presentation.PresentationReconciler; +import org.eclipse.jface.text.rules.DefaultDamagerRepairer; +import org.eclipse.jface.text.rules.ITokenScanner; +import org.eclipse.jface.text.rules.RuleBasedScanner; +import org.eclipse.jface.text.source.ISourceViewer; +import org.eclipse.ui.editors.text.TextSourceViewerConfiguration; + +/** + * Configures the Source page's viewer: XML syntax coloring over the partitioning that + * {@link XmlDocumentSetupParticipant} installs, and the text editor defaults for everything else. + * + * @since 1.0.0 + */ +public final class XmlSourceViewerConfiguration extends TextSourceViewerConfiguration +{ + private final XmlTokenColors tokenColors; + + /** + * Creates the configuration. + * + * @param preferenceStore The preference store of the text editor defaults, such as the tab width. + * @param tokenColors The colored tokens of the syntax coloring. + */ + public XmlSourceViewerConfiguration(final IPreferenceStore preferenceStore, + final XmlTokenColors tokenColors) + { + super(preferenceStore); + this.tokenColors = tokenColors; + } + + /** + * Returns the XML partitioning. + * + * @param sourceViewer The viewer to configure. + * @return The identifier of the XML partitioning, {@link XmlDocumentSetupParticipant#PARTITIONING}. + */ + @Override + public String getConfiguredDocumentPartitioning(final ISourceViewer sourceViewer) + { + return XmlDocumentSetupParticipant.PARTITIONING; + } + + /** + * Returns the content types of the XML partitioning. + * + * @param sourceViewer The viewer to configure. + * @return The default content type followed by the comment, processing instruction, DOCTYPE, and tag + * content types. + */ + @Override + public String[] getConfiguredContentTypes(final ISourceViewer sourceViewer) + { + return new String[] { IDocument.DEFAULT_CONTENT_TYPE, XmlPartitionScanner.COMMENT, + XmlPartitionScanner.PROCESSING_INSTRUCTION, XmlPartitionScanner.DOCTYPE, + XmlPartitionScanner.TAG }; + } + + /** + * Returns a presentation reconciler that colors each partition: tags by their parts, comments, + * processing instructions, and the DOCTYPE each in one color, and text between tags in the default + * color. + * + * @param sourceViewer The viewer to configure. + * @return The presentation reconciler. + */ + @Override + public IPresentationReconciler getPresentationReconciler(final ISourceViewer sourceViewer) + { + final PresentationReconciler reconciler = new PresentationReconciler(); + reconciler.setDocumentPartitioning(getConfiguredDocumentPartitioning(sourceViewer)); + final XmlTagScanner tagScanner = new XmlTagScanner(tokenColors.getToken(XmlTokenColors.TAG_COLOR), + tokenColors.getToken(XmlTokenColors.ATTRIBUTE_NAME_COLOR), + tokenColors.getToken(XmlTokenColors.ATTRIBUTE_VALUE_COLOR)); + setDamagerRepairer(reconciler, XmlPartitionScanner.TAG, new WholeTagDamagerRepairer(tagScanner)); + setDamagerRepairer(reconciler, XmlPartitionScanner.COMMENT, + singleColorScanner(XmlTokenColors.COMMENT_COLOR)); + setDamagerRepairer(reconciler, XmlPartitionScanner.PROCESSING_INSTRUCTION, + singleColorScanner(XmlTokenColors.DECLARATION_COLOR)); + setDamagerRepairer(reconciler, XmlPartitionScanner.DOCTYPE, + singleColorScanner(XmlTokenColors.DECLARATION_COLOR)); + setDamagerRepairer(reconciler, IDocument.DEFAULT_CONTENT_TYPE, new RuleBasedScanner()); + return reconciler; + } + + private RuleBasedScanner singleColorScanner(final String colorId) + { + final RuleBasedScanner scanner = new RuleBasedScanner(); + scanner.setDefaultReturnToken(tokenColors.getToken(colorId)); + return scanner; + } + + private static void setDamagerRepairer(final PresentationReconciler reconciler, final String contentType, + final ITokenScanner scanner) + { + setDamagerRepairer(reconciler, contentType, new DefaultDamagerRepairer(scanner)); + } + + private static void setDamagerRepairer(final PresentationReconciler reconciler, final String contentType, + final DefaultDamagerRepairer damagerRepairer) + { + reconciler.setDamager(damagerRepairer, contentType); + reconciler.setRepairer(damagerRepairer, contentType); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/XmlTagScanner.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/XmlTagScanner.java new file mode 100644 index 00000000..733f108b --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/XmlTagScanner.java @@ -0,0 +1,97 @@ +/* + * + * 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.eclipse.dataset.ui.source; + +import org.eclipse.jface.text.rules.ICharacterScanner; +import org.eclipse.jface.text.rules.IRule; +import org.eclipse.jface.text.rules.IToken; +import org.eclipse.jface.text.rules.MultiLineRule; +import org.eclipse.jface.text.rules.RuleBasedScanner; +import org.eclipse.jface.text.rules.Token; + +/** + * Scans the inside of a tag partition: quoted attribute values, attribute names, and, with the tag + * token, the element name and everything else. + * + * @since 1.0.0 + */ +final class XmlTagScanner extends RuleBasedScanner +{ + XmlTagScanner(final IToken tagToken, final IToken attributeNameToken, final IToken attributeValueToken) + { + setRules(new IRule[] { + new MultiLineRule("\"", "\"", attributeValueToken, (char) 0, true), + new MultiLineRule("'", "'", attributeValueToken, (char) 0, true), + new NameRule(tagToken, attributeNameToken) }); + setDefaultReturnToken(tagToken); + } + + /** + * Matches a name: an attribute name when an equals sign follows it, possibly after whitespace, and + * otherwise the element name. + */ + private static final class NameRule implements IRule + { + private static final String DELIMITERS = "<>/=\"'"; + + private final IToken elementNameToken; + + private final IToken attributeNameToken; + + NameRule(final IToken elementNameToken, final IToken attributeNameToken) + { + this.elementNameToken = elementNameToken; + this.attributeNameToken = attributeNameToken; + } + + @Override + public IToken evaluate(final ICharacterScanner scanner) + { + int c = scanner.read(); + if (!isNameCharacter(c)) + { + scanner.unread(); + return Token.UNDEFINED; + } + while (isNameCharacter(c)) + { + c = scanner.read(); + } + int readAhead = 1; + while (Character.isWhitespace(c)) + { + c = scanner.read(); + readAhead++; + } + final IToken token = c == '=' ? attributeNameToken : elementNameToken; + for (int i = 0; i < readAhead; i++) + { + scanner.unread(); + } + return token; + } + + private static boolean isNameCharacter(final int c) + { + return c != ICharacterScanner.EOF && !Character.isWhitespace(c) && DELIMITERS.indexOf(c) < 0; + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/XmlTokenColors.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/XmlTokenColors.java new file mode 100644 index 00000000..fff552af --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/XmlTokenColors.java @@ -0,0 +1,127 @@ +/* + * + * 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.eclipse.dataset.ui.source; + +import java.util.Map; + +import org.eclipse.jface.resource.ColorRegistry; +import org.eclipse.jface.text.TextAttribute; +import org.eclipse.jface.text.rules.IToken; +import org.eclipse.jface.text.rules.Token; +import org.eclipse.jface.util.IPropertyChangeListener; +import org.eclipse.jface.util.PropertyChangeEvent; +import org.eclipse.ui.themes.IThemeManager; + +/** + * The colored tokens of the Source page's XML coloring. Their colors come from the theme color + * definitions that {@code plugin.xml} declares, and follow theme and color preference changes. + * + * @since 1.0.0 + */ +public final class XmlTokenColors +{ + /** + * The theme color definition of tag delimiters and element names. + */ + public static final String TAG_COLOR = "org.dbunit.eclipse.dataset.ui.source.tagColor"; + + /** + * The theme color definition of attribute names. + */ + public static final String ATTRIBUTE_NAME_COLOR = + "org.dbunit.eclipse.dataset.ui.source.attributeNameColor"; + + /** + * The theme color definition of quoted attribute values. + */ + public static final String ATTRIBUTE_VALUE_COLOR = + "org.dbunit.eclipse.dataset.ui.source.attributeValueColor"; + + /** + * The theme color definition of comments. + */ + public static final String COMMENT_COLOR = "org.dbunit.eclipse.dataset.ui.source.commentColor"; + + /** + * The theme color definition of processing instructions and the DOCTYPE. + */ + public static final String DECLARATION_COLOR = "org.dbunit.eclipse.dataset.ui.source.declarationColor"; + + private final Map tokens = Map.of(TAG_COLOR, new Token(null), ATTRIBUTE_NAME_COLOR, + new Token(null), ATTRIBUTE_VALUE_COLOR, new Token(null), COMMENT_COLOR, new Token(null), + DECLARATION_COLOR, new Token(null)); + + private final IPropertyChangeListener themeListener = this::themeChanged; + + private final IThemeManager themeManager; + + private final Runnable colorsChanged; + + /** + * Creates the tokens with the current theme's colors and starts following theme changes. + * + * @param themeManager The theme manager whose current theme supplies the colors. + * @param colorsChanged The action to run after a theme change has updated the tokens' colors, for + * example to redraw the text that uses them. + */ + public XmlTokenColors(final IThemeManager themeManager, final Runnable colorsChanged) + { + this.themeManager = themeManager; + this.colorsChanged = colorsChanged; + readColors(); + themeManager.addPropertyChangeListener(themeListener); + } + + /** + * Returns the token colored by a theme color definition; its color changes with the theme. + * + * @param colorId One of the color definition identifiers of this class, such as {@link #TAG_COLOR}. + * @return The token, whose data is a {@link TextAttribute} with the color as its foreground. + */ + public IToken getToken(final String colorId) + { + return tokens.get(colorId); + } + + /** + * Stops following theme changes. + */ + public void dispose() + { + themeManager.removePropertyChangeListener(themeListener); + } + + private void themeChanged(final PropertyChangeEvent event) + { + final String property = event.getProperty(); + if (IThemeManager.CHANGE_CURRENT_THEME.equals(property) || tokens.containsKey(property)) + { + readColors(); + colorsChanged.run(); + } + } + + private void readColors() + { + final ColorRegistry colorRegistry = themeManager.getCurrentTheme().getColorRegistry(); + tokens.forEach((colorId, token) -> token.setData(new TextAttribute(colorRegistry.get(colorId)))); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/wizards/NewFlatXmlDatasetPage.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/wizards/NewFlatXmlDatasetPage.java new file mode 100644 index 00000000..eed558ba --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/wizards/NewFlatXmlDatasetPage.java @@ -0,0 +1,92 @@ +/* + * + * 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.eclipse.dataset.ui.wizards; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.ILog; +import org.eclipse.core.runtime.IPath; +import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.ui.dialogs.WizardNewFileCreationPage; + +/** + * The page of {@link NewFlatXmlDatasetWizard}: the folder and name of the new file, which starts as an + * empty dataset. + * + * @since 1.0.0 + */ +final class NewFlatXmlDatasetPage extends WizardNewFileCreationPage +{ + private static final ILog LOG = ILog.of(NewFlatXmlDatasetPage.class); + + static final String PAGE_NAME = "newFlatXmlDatasetPage"; + + static final String DEFAULT_FILE_NAME = "dataset.xml"; + + private static final String FILE_EXTENSION = "xml"; + + NewFlatXmlDatasetPage(final IStructuredSelection selection) + { + super(PAGE_NAME, selection); + setTitle(Messages.NewDatasetWizard_pageTitle); + setDescription(Messages.NewDatasetWizard_pageDescription); + setFileName(DEFAULT_FILE_NAME); + setFileExtension(FILE_EXTENSION); + } + + @Override + protected InputStream getInitialContents() + { + final String text = FlatXmlDatasetDocument.emptyDatasetText(lineDelimiter()); + return new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Returns the line delimiter for new files in the project of the chosen folder, as the text editors + * use it: the project's preference, else the workspace's, else the operating system's. + */ + private String lineDelimiter() + { + final IPath containerPath = getContainerFullPath(); + if (containerPath == null || containerPath.segmentCount() == 0) + { + return System.lineSeparator(); + } + final String projectName = containerPath.segment(0); + final IProject project = ResourcesPlugin.getWorkspace().getRoot().getProject(projectName); + try + { + return project.getDefaultLineSeparator(); + } + catch (final CoreException e) + { + LOG.log(e.getStatus()); + return System.lineSeparator(); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/wizards/NewFlatXmlDatasetWizard.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/wizards/NewFlatXmlDatasetWizard.java new file mode 100644 index 00000000..97023e9c --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/wizards/NewFlatXmlDatasetWizard.java @@ -0,0 +1,132 @@ +/* + * + * 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.eclipse.dataset.ui.wizards; + +import org.dbunit.eclipse.dataset.ui.DatasetImages; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.eclipse.core.resources.IFile; +import org.eclipse.jface.dialogs.ErrorDialog; +import org.eclipse.jface.viewers.IStructuredSelection; +import org.eclipse.jface.wizard.Wizard; +import org.eclipse.ui.INewWizard; +import org.eclipse.ui.IWorkbench; +import org.eclipse.ui.IWorkbenchPage; +import org.eclipse.ui.IWorkbenchWindow; +import org.eclipse.ui.PartInitException; +import org.eclipse.ui.ide.IDE; +import org.eclipse.ui.wizards.newresource.BasicNewResourceWizard; + +/** + * The New dbUnit Flat XML Dataset wizard: creates a file that holds an empty flat XML dataset and opens + * it. + * + * @since 1.0.0 + */ +public final class NewFlatXmlDatasetWizard extends Wizard implements INewWizard +{ + /** + * The identifier this wizard is registered under in {@code plugin.xml}. + */ + public static final String ID = "org.dbunit.eclipse.dataset.ui.newFlatXmlDatasetWizard"; + + private IWorkbench workbench; + + private IStructuredSelection selection; + + private NewFlatXmlDatasetPage page; + + /** + * Creates the wizard, which the workbench then initializes with + * {@link #init(IWorkbench, IStructuredSelection)}. + */ + public NewFlatXmlDatasetWizard() + { + setWindowTitle(Messages.NewDatasetWizard_windowTitle); + setDefaultPageImageDescriptor(DatasetImages.getImageDescriptor(DatasetImages.IMG_NEW_DATASET_WIZBAN)); + setNeedsProgressMonitor(true); + } + + /** + * Remembers the workbench and the selection, whose first resource proposes the folder of the new file. + * + * @param workbench The workbench. + * @param selection The selection when the wizard started. + */ + @Override + public void init(final IWorkbench workbench, final IStructuredSelection selection) + { + this.workbench = workbench; + this.selection = selection; + } + + /** + * Adds the page that asks for the folder and name of the new file. + */ + @Override + public void addPages() + { + page = new NewFlatXmlDatasetPage(selection); + addPage(page); + } + + /** + * Creates the file, selects it in the views of the active workbench window, and opens it in its + * default editor, which is the dbUnit Dataset Editor unless the user associated another editor with + * dbUnit flat XML datasets. + * + * @return True when the file was created; false when it was not, which keeps the wizard open. + */ + @Override + public boolean performFinish() + { + final IFile file = page.createNewFile(); + if (file == null) + { + return false; + } + final IWorkbenchWindow window = workbench.getActiveWorkbenchWindow(); + BasicNewResourceWizard.selectAndReveal(file, window); + openEditor(window, file); + return true; + } + + private static void openEditor(final IWorkbenchWindow window, final IFile file) + { + if (window == null) + { + return; + } + final IWorkbenchPage workbenchPage = window.getActivePage(); + if (workbenchPage == null) + { + return; + } + try + { + IDE.openEditor(workbenchPage, file, true); + } + catch (final PartInitException e) + { + ErrorDialog.openError(window.getShell(), Messages.NewDatasetWizard_openErrorTitle, null, + e.getStatus()); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/DatasetImagesTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/DatasetImagesTest.java new file mode 100644 index 00000000..0b287b01 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/DatasetImagesTest.java @@ -0,0 +1,52 @@ +/* + * + * 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.eclipse.dataset.ui; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.eclipse.swt.graphics.Image; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link DatasetImages} against the requirement that every declared key loads an image. + */ +class DatasetImagesTest +{ + @Test + void testGetImage_forEveryDeclaredKey_returnsAUsableImage() + { + final List keys = List.of(DatasetImages.IMG_DATASET, DatasetImages.IMG_INSERT_ROW_ABOVE, + DatasetImages.IMG_INSERT_ROW_BELOW, DatasetImages.IMG_DUPLICATE_ROWS, + DatasetImages.IMG_DELETE_ROWS, DatasetImages.IMG_ADD_COLUMN, + DatasetImages.IMG_DELETE_COLUMN, DatasetImages.IMG_ADD_TABLE, DatasetImages.IMG_SET_NULL, + DatasetImages.IMG_FILL_DOWN, DatasetImages.IMG_NEW_DATASET_WIZBAN); + + for (final String key : keys) + { + final Image image = DatasetImages.getImage(key); + assertThat(image).as("Key '" + key + "' must load an image.").isNotNull(); + assertThat(image.isDisposed()).as("Key '" + key + "' must load a usable, undisposed image.") + .isFalse(); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/MessagesTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/MessagesTest.java new file mode 100644 index 00000000..f10f8dcf --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/MessagesTest.java @@ -0,0 +1,90 @@ +/* + * + * 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.eclipse.dataset.ui; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.Field; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.eclipse.osgi.util.NLS; +import org.junit.jupiter.api.Test; + +/** + * Tests that {@link Messages} loads every message from {@code messages.properties}, and that the messages + * follow the quoting rules of {@link NLS#bind}. + */ +class MessagesTest +{ + @Test + void testMessages_whenLoaded_haveAValueForEveryField() throws IllegalAccessException + { + final Map messages = messages(); + + assertThat(messages).as("Every message must have an entry in messages.properties.") + .allSatisfy((name, message) -> assertThat(message).as(name) + .doesNotStartWith("NLS missing message")); + } + + @Test + void testBind_ofEachMessageWithArguments_substitutesThemAllAndShowsEachApostropheOnce() + throws IllegalAccessException + { + final Map bound = new LinkedHashMap<>(); + messages().forEach((name, message) -> + { + if (message.contains("{0}")) + { + bound.put(name, NLS.bind(message, new Object[] { "a", "b", "c" })); + } + }); + + assertThat(bound).as("A bound message must replace every argument and write '' as one apostrophe.") + .isNotEmpty() + .allSatisfy((name, message) -> assertThat(message).as(name).doesNotContain("{", "''")); + } + + @Test + void testMessages_withoutArguments_containNoDoubledApostrophes() throws IllegalAccessException + { + final Map messages = messages(); + + assertThat(messages).as("A message shown without binding must write an apostrophe as one character.") + .allSatisfy((name, message) -> + { + if (!message.contains("{0}")) + { + assertThat(message).as(name).doesNotContain("''"); + } + }); + } + + private static Map messages() throws IllegalAccessException + { + final Map messages = new LinkedHashMap<>(); + for (final Field field : Messages.class.getFields()) + { + messages.put(field.getName(), (String) field.get(null)); + } + return messages; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/AddColumnActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/AddColumnActionTest.java new file mode 100644 index 00000000..3afe603c --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/AddColumnActionTest.java @@ -0,0 +1,84 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.eclipse.jface.dialogs.IInputValidator; +import org.eclipse.swt.widgets.Shell; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link AddColumnAction} against the Commands specification: the column that the dialog names is + * added as a pending column, and the dialog tells when the DTD declares columns. + */ +class AddColumnActionTest extends GridActionFixture +{ + @Test + void testAddColumn_whenTheDialogReturnsAName_addsThePendingColumn() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + final AddColumnAction action = new AddColumnAction(context) + { + @Override + String openNameDialog(final Shell shell, final IInputValidator validator, + final DatasetTable table) + { + return "EMAIL"; + } + }; + + action.run(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .extracting(DatasetColumn::name).as("Add Column must add the entered name.") + .contains("EMAIL"); + } + + @Test + void testAddColumnNameDialogMessage_whenTheTableHasNoDtdDeclaredColumns_isJustThePrompt() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + + assertThat(AddColumnAction.nameDialogMessage(table)) + .as("A table with no DTD-declared columns must get a plain prompt.") + .isEqualTo("Column name:"); + } + + @Test + void testAddColumnNameDialogMessage_whenTheTableHasDtdDeclaredColumns_warnsThatDbUnitReadsTheDtd() + { + final FlatXmlDatasetDocument datasetDocument = create( + "\n\n" + + "\n]>\n\n \n" + + "\n"); + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + + assertThat(AddColumnAction.nameDialogMessage(table)) + .as("A table with DTD-declared columns must warn that dbUnit reads columns from the DTD.") + .contains("dbUnit reads a flat XML dataset's columns from its DTD"); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/AddTableActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/AddTableActionTest.java new file mode 100644 index 00000000..2ea8b36c --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/AddTableActionTest.java @@ -0,0 +1,132 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.dbunit.eclipse.dataset.ui.dialogs.AddTableDialog; +import org.eclipse.jface.dialogs.IInputValidator; +import org.eclipse.swt.widgets.Shell; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link AddTableAction} against the Commands specification: the table that the dialog names is + * added, a rejected edit adds nothing, and the dialog refuses the name of the root element. + */ +class AddTableActionTest extends GridActionFixture +{ + @Test + void testAddTable_whenTheDialogReturnsANameAndColumns_addsTheTable() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + final AddTableAction action = new AddTableAction(context) + { + @Override + AddTableDialog openDialog(final Shell shell, final IInputValidator nameValidator) + { + return new AddTableDialog(shell, nameValidator) + { + @Override + public String getTableName() + { + return "Accounts"; + } + + @Override + public List getColumnNames() + { + return List.of("ID", "BALANCE"); + } + }; + } + }; + + action.run(); + + final DatasetTable table = datasetDocument.getModel().findTable("ACCOUNTS").orElseThrow(); + assertThat(table.getColumns()).extracting(DatasetColumn::name) + .as("Add Table must create the table with the entered columns.") + .containsExactly("ID", "BALANCE"); + } + + @Test + void testAddTable_whenTheEditIsRejected_createsNoTable() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.editable = false; + final AddTableAction action = new AddTableAction(context) + { + @Override + AddTableDialog openDialog(final Shell shell, final IInputValidator nameValidator) + { + return new AddTableDialog(shell, nameValidator) + { + @Override + public String getTableName() + { + return "Accounts"; + } + + @Override + public List getColumnNames() + { + return List.of(); + } + }; + } + }; + + action.run(); + + assertThat(datasetDocument.getModel().findTable("ACCOUNTS")) + .as("A rejected edit must not create the table.").isEmpty(); + } + + @Test + void testAddTable_whenTheEnteredNameIsTheReservedRootName_theDialogValidatorRejectsIt() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + final List capturedValidator = new ArrayList<>(); + final AddTableAction action = new AddTableAction(context) + { + @Override + AddTableDialog openDialog(final Shell shell, final IInputValidator nameValidator) + { + capturedValidator.add(nameValidator); + return null; + } + }; + + action.run(); + + assertThat(capturedValidator.get(0).isValid("dataset")) + .as("Add Table must reject the name reserved for the root element.").isNotNull(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/CellValueChangesTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/CellValueChangesTest.java new file mode 100644 index 00000000..c4e936ea --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/CellValueChangesTest.java @@ -0,0 +1,87 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.dbunit.eclipse.dataset.core.dtd.DtdSource; +import org.dbunit.eclipse.dataset.core.edit.CellChange; +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlOptions; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.eclipse.jface.text.Document; +import org.eclipse.swt.graphics.Point; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link CellValueChanges}: one change for each cell, with the row of the cell, the name of its column, + * and the value. + */ +class CellValueChangesTest +{ + @Test + void testSetting_forCellsOfSeveralRowsAndColumns_returnsOneChangeForEachInTheOrderOfTheCells() + { + final DatasetTable table = usersTable(); + final List cells = List.of(new Point(1, 0), new Point(0, 1), new Point(1, 1)); + + final List changes = CellValueChanges.setting(cells, table, "x"); + + assertThat(changes).as("Each cell must get its row, its column's name, and the value.") + .containsExactly(new CellChange(0, "NAME", "x"), new CellChange(1, "ID", "x"), + new CellChange(1, "NAME", "x")); + } + + @Test + void testSetting_forNullAndForTheEmptyString_keepsTheValueAsGiven() + { + final DatasetTable table = usersTable(); + final List cells = List.of(new Point(0, 0)); + + final List> changes = List.of(CellValueChanges.setting(cells, table, null), + CellValueChanges.setting(cells, table, "")); + + assertThat(changes).as("NULL must stay NULL and the empty string must stay the empty string.") + .containsExactly(List.of(new CellChange(0, "ID", null)), + List.of(new CellChange(0, "ID", ""))); + } + + @Test + void testSetting_forNoCells_returnsNoChanges() + { + final List changes = CellValueChanges.setting(List.of(), usersTable(), "x"); + + assertThat(changes).as("Without cells there is nothing to change.").isEmpty(); + } + + private static DatasetTable usersTable() + { + final String text = + ""; + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(new Document(text), + DtdSource.NONE, FlatXmlOptions.DBUNIT_DEFAULTS, () -> StandardCharsets.UTF_8); + datasetDocument.refresh(); + return datasetDocument.requireTable("USERS"); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/CopyActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/CopyActionTest.java new file mode 100644 index 00000000..c6e968c2 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/CopyActionTest.java @@ -0,0 +1,193 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.eclipse.swt.SWT; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.widgets.Text; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link CopyAction} against the Clipboard specification: what is copied from a block and from a + * selection that is not a block, the refusal of a selection whose rows have different columns, the + * default value of a cell, and the text of a cell editor. + */ +class CopyActionTest extends GridActionFixture +{ + @Test + void testCopy_ofA2x2Range_putsTabSeparatedTextOnTheClipboard() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 1); + context.columnIndexes = List.of(0, 1); + context.selectedCellPositions = + List.of(new Point(0, 0), new Point(1, 0), new Point(0, 1), new Point(1, 1)); + final CopyAction action = new CopyAction(context); + + action.run(); + + assertThat(context.clipboardText).as("Copy must put the range as tab-separated text.") + .isEqualTo("1\tAlice" + System.lineSeparator() + "2\tBob" + System.lineSeparator()); + } + + @Test + void testCopy_ofNonAdjacentWholeRows_copiesOnlyTheSelectedRows() + { + final FlatXmlDatasetDocument datasetDocument = create("" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 2); + context.columnIndexes = List.of(0, 1); + context.wholeRowsSelected = true; + context.selectedCellPositions = + List.of(new Point(0, 0), new Point(1, 0), new Point(0, 2), new Point(1, 2)); + final CopyAction action = new CopyAction(context); + + action.run(); + + assertThat(context.clipboardText) + .as("The row between two rows selected with Ctrl is not selected, so it must not be " + + "copied as an empty row.") + .isEqualTo("1\tAlice" + System.lineSeparator() + "3\tCarol" + System.lineSeparator()); + assertThat(context.statusErrorMessage).as("Rows that line up are copyable.").isNull(); + } + + @Test + void testCopy_ofNonAdjacentColumns_copiesOnlyTheSelectedColumns() + { + final FlatXmlDatasetDocument datasetDocument = create("" + + "" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 1); + context.columnIndexes = List.of(0, 2); + context.selectedCellPositions = + List.of(new Point(0, 0), new Point(2, 0), new Point(0, 1), new Point(2, 1)); + final CopyAction action = new CopyAction(context); + + action.run(); + + assertThat(context.clipboardText) + .as("The column between two columns selected with Ctrl is not selected, so it must not be " + + "copied as an empty column.") + .isEqualTo("1\tRome" + System.lineSeparator() + "2\tParis" + System.lineSeparator()); + } + + @Test + void testCopy_ofNonAdjacentCellsInOneColumn_copiesThemNextToEachOther() + { + final FlatXmlDatasetDocument datasetDocument = create("" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 2); + context.columnIndexes = List.of(1); + context.selectedCellPositions = List.of(new Point(1, 0), new Point(1, 2)); + final CopyAction action = new CopyAction(context); + + action.run(); + + assertThat(context.clipboardText) + .as("Two cells of one column selected with Ctrl must copy as two adjacent cells.") + .isEqualTo("Alice" + System.lineSeparator() + "Carol" + System.lineSeparator()); + } + + @Test + void testCopy_whenTheSelectedRowsHaveDifferentSelectedColumns_refusesAndLeavesTheClipboardAlone() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 1); + context.columnIndexes = List.of(0, 1); + context.selectedCellPositions = List.of(new Point(0, 0), new Point(1, 1)); + context.clipboardText = "kept"; + final CopyAction action = new CopyAction(context); + + action.run(); + + assertThat(context.clipboardText) + .as("A selection with no sensible rectangle must copy nothing, not a rectangle with empty " + + "cells in the gaps, which pastes as NULL.") + .isEqualTo("kept"); + assertThat(context.statusErrorMessage).as("Copy must say why it copied nothing.") + .isEqualTo("Cannot copy or cut this selection, because its rows have different selected " + + "columns. Select the same columns in every selected row."); + } + + @Test + void testCopy_whileACellEditorIsActive_actsOnTheEditorsTextInsteadOfTheGrid() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.hasActiveCellEditor = true; + final Text text = new Text(shell, SWT.NONE); + text.setText("hello world"); + text.setSelection(0, 5); + context.activeCellEditorText = text; + final CopyAction action = new CopyAction(context); + + action.run(); + + assertThat(text.getSelectionText()).as("The editor's own selection is what Copy must act on.") + .isEqualTo("hello"); + assertThat(context.clipboardText) + .as("With an active cell editor, Copy must not use the grid-level clipboard write.") + .isNull(); + } + + @Test + void testUpdate_forCopy_staysEnabledWhileACellEditorIsActive() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0); + context.columnIndexes = List.of(0); + context.hasActiveCellEditor = true; + final CopyAction action = new CopyAction(context); + + action.update(context.getSelection()); + + assertThat(action.isEnabled()).as("Copy must stay enabled while a cell editor is active.").isTrue(); + } + + @Test + void testCopy_ofACellThatShowsADefaultValue_copiesTheDefault() + { + final FlatXmlDatasetDocument datasetDocument = create(DEFAULTS_DATASET); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0); + context.columnIndexes = List.of(0, 1); + context.selectedCellPositions = List.of(new Point(0, 0), new Point(1, 0)); + + new CopyAction(context).run(); + + assertThat(context.clipboardText) + .as("Copy must put the value dbUnit loads, so a cell without a value copies its default.") + .isEqualTo("1\tACTIVE" + System.lineSeparator()); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/CutActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/CutActionTest.java new file mode 100644 index 00000000..6b6752d7 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/CutActionTest.java @@ -0,0 +1,131 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.eclipse.swt.graphics.Point; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link CutAction} against the Clipboard specification: the selection is copied and then cleared or + * deleted, and a selection that cannot be copied is refused. + */ +class CutActionTest extends GridActionFixture +{ + @Test + void testCut_whenTheSelectedRowsHaveDifferentSelectedColumns_refusesAndChangesNothing() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 1); + context.columnIndexes = List.of(0, 1); + context.selectedCellPositions = List.of(new Point(0, 0), new Point(1, 1)); + context.clipboardText = "kept"; + final CutAction action = new CutAction(context); + + action.run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows()).extracting(row -> row.getValues()) + .as("Cut copies before it clears, so a selection that cannot be copied must clear " + + "nothing.") + .containsExactly(List.of("1", "Alice"), List.of("2", "Bob")); + assertThat(context.clipboardText).as("Cut must leave the clipboard alone.").isEqualTo("kept"); + assertThat(context.statusErrorMessage).as("Cut must say why it did nothing.") + .startsWith("Cannot copy or cut this selection"); + } + + @Test + void testCut_ofNonAdjacentWholeRows_copiesJustThoseRowsThenDeletesThem() + { + final FlatXmlDatasetDocument datasetDocument = create("" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 2); + context.columnIndexes = List.of(0, 1); + context.wholeRowsSelected = true; + context.selectedCellPositions = + List.of(new Point(0, 0), new Point(1, 0), new Point(0, 2), new Point(1, 2)); + final CutAction action = new CutAction(context); + + action.run(); + + assertThat(context.clipboardText).as("Cut must copy the two rows without the row between them.") + .isEqualTo("1\tAlice" + System.lineSeparator() + "3\tCarol" + System.lineSeparator()); + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows()).extracting(row -> row.getValues()) + .as("Cut must delete the two selected rows and keep the row between them.") + .containsExactly(List.of("2", "Bob")); + } + + @Test + void testCut_ofACellRange_copiesThenSetsTheSelectedCellsToNull() + { + final FlatXmlDatasetDocument datasetDocument = create("" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(1, 2); + context.columnIndexes = List.of(1); + context.selectedCellPositions = List.of(new Point(1, 1), new Point(1, 2)); + context.wholeRowsSelected = false; + final CutAction action = new CutAction(context); + + action.run(); + + assertThat(context.clipboardText).as("Cut must copy the selection before clearing it.") + .isEqualTo("Bob" + System.lineSeparator() + "Carol" + System.lineSeparator()); + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows().get(1).getValue(1)).as("Cut must set the selected cells to NULL.") + .isNull(); + assertThat(table.getRows().get(2).getValue(1)).isNull(); + assertThat(table.getRows().get(0).getValue(1)).as("Cut must not touch an unselected row.") + .isEqualTo("Alice"); + assertThat(table.getRows().get(1).getValue(0)).as("Cut must not touch an unselected column.") + .isEqualTo("2"); + } + + @Test + void testCut_ofWholeRows_deletesThemInsteadOfSettingThemToNull() + { + final FlatXmlDatasetDocument datasetDocument = create("" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 1); + context.columnIndexes = List.of(0, 1); + context.selectedCellPositions = + List.of(new Point(0, 0), new Point(1, 0), new Point(0, 1), new Point(1, 1)); + context.wholeRowsSelected = true; + final CutAction action = new CutAction(context); + + action.run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows()).as("Cutting whole rows must delete them, not just clear them.") + .hasSize(1); + assertThat(table.getRows().get(0).getValue(0)).isEqualTo("3"); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/DeleteActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/DeleteActionTest.java new file mode 100644 index 00000000..40951f77 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/DeleteActionTest.java @@ -0,0 +1,166 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.eclipse.swt.SWT; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.widgets.Text; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link DeleteAction} against the Commands specification: the selected cells are set to NULL, whole + * rows are deleted, the note about a default value, and the text of a cell editor is edited instead. + */ +class DeleteActionTest extends GridActionFixture +{ + @Test + void testDelete_ofSelectedCells_setsThemToNull() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(1); + context.columnIndexes = List.of(1); + context.selectedCellPositions = List.of(new Point(1, 1)); + final DeleteAction action = new DeleteAction(context); + + action.run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows().get(1).getValue(1)).as("Delete must set the selected cells to NULL.") + .isNull(); + assertThat(table.getRows().get(1).getValue(0)).isEqualTo("2"); + assertThat(table.getRows().get(0).getValue(1)).as("Delete must not touch an unselected row.") + .isEqualTo("Alice"); + } + + @Test + void testDelete_ofWholeRows_deletesThemInsteadOfSettingThemToNull() + { + final FlatXmlDatasetDocument datasetDocument = create("" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 1); + context.columnIndexes = List.of(0, 1); + context.selectedCellPositions = + List.of(new Point(0, 0), new Point(1, 0), new Point(0, 1), new Point(1, 1)); + context.wholeRowsSelected = true; + final DeleteAction action = new DeleteAction(context); + + action.run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows()).extracting(row -> row.getValues()) + .as("Delete must remove whole rows, because setting every cell of a row to NULL is " + + "rejected.") + .containsExactly(List.of("3", "Carol")); + assertThat(context.statusErrorMessage).as("Nothing may be rejected.").isNull(); + } + + @Test + void testDelete_ofWholeRows_selectsTheRowThatTakesTheirPlaceInTheAnchorColumn() + { + final FlatXmlDatasetDocument datasetDocument = create("" + + "" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.anchorColumnIndex = 1; + context.anchorRowIndex = 1; + context.rowIndexes = List.of(1, 2); + context.columnIndexes = List.of(0, 1); + context.selectedCellPositions = + List.of(new Point(0, 1), new Point(1, 1), new Point(0, 2), new Point(1, 2)); + context.wholeRowsSelected = true; + + new DeleteAction(context).run(); + + assertThat(context.selectedRegion) + .as("Deleting rows with Delete must leave the grid as Delete Rows does: with the row " + + "that took the first deleted row's place selected, so Delete can go on.") + .isEqualTo(new Rectangle(1, 1, 1, 1)); + } + + @Test + void testDelete_ofWholeRowsThatTheFirstRowRuleRefuses_reportsItThroughTheDeleteRowsDialog() + { + final FlatXmlDatasetDocument datasetDocument = + create("" + + "" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0); + context.columnIndexes = List.of(0, 1, 2); + context.selectedCellPositions = List.of(new Point(0, 0), new Point(1, 0), new Point(2, 0)); + context.wholeRowsSelected = true; + + new DeleteAction(context).run(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows()) + .as("Bob would become the first row without an email, which dbUnit would ignore in Carl.") + .hasSize(3); + assertThat(context.multiCellEditTitles) + .as("Deleting whole rows is the Delete Rows command, so a refusal must be reported through " + + "its modal dialog, not just the status line.") + .containsExactly("Delete Rows"); + } + + @Test + void testDelete_whenTheColumnHasADefaultValue_explainsOnTheStatusLineThatDbUnitLoadsTheDefault() + { + final FlatXmlDatasetDocument datasetDocument = create(DEFAULTS_DATASET); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(1); + context.columnIndexes = List.of(1); + context.selectedCellPositions = List.of(new Point(1, 1)); + + new DeleteAction(context).run(); + + assertThat(context.statusMessage).as("Delete sets cells to NULL, so it must give the same notice.") + .isEqualTo("Column \"STATUS\" has the DTD default value \"ACTIVE\", so dbUnit loads it, " + + "not NULL, for a cell without a value."); + } + + @Test + void testDelete_whileEditingWithCaretBeforeASurrogatePair_deletesTheWholeCharacter() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.hasActiveCellEditor = true; + final Text text = new Text(shell, SWT.NONE); + text.setText("a😀b"); + text.setSelection(1); + context.activeCellEditorText = text; + final DeleteAction action = new DeleteAction(context); + + action.run(); + + assertThat(text.getText()) + .as("Forward-delete must remove the whole surrogate pair, not just its high surrogate.") + .isEqualTo("ab"); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/DeleteColumnActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/DeleteColumnActionTest.java new file mode 100644 index 00000000..30edfd6e --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/DeleteColumnActionTest.java @@ -0,0 +1,85 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.eclipse.swt.widgets.Shell; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link DeleteColumnAction} against the Commands specification: the anchor column is deleted only + * when the user confirms. + */ +class DeleteColumnActionTest extends GridActionFixture +{ + @Test + void testDeleteColumn_whenConfirmed_deletesTheAnchorColumnNamedWithItsValueCount() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.anchorColumnIndex = 1; + final DeleteColumnAction action = new DeleteColumnAction(context) + { + @Override + boolean confirmDelete(final Shell shell, final DatasetColumn column, final int valueCount) + { + assertThat(column.name()).as("The confirmation must name the anchor column.") + .isEqualTo("NAME"); + assertThat(valueCount).as("The confirmation must count the column's current values.") + .isEqualTo(1); + return true; + } + }; + + action.run(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .extracting(DatasetColumn::name).as("Confirmed deletion must remove the column.") + .doesNotContain("NAME"); + } + + @Test + void testDeleteColumn_whenNotConfirmed_changesNothing() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.anchorColumnIndex = 1; + final DeleteColumnAction action = new DeleteColumnAction(context) + { + @Override + boolean confirmDelete(final Shell shell, final DatasetColumn column, final int valueCount) + { + return false; + } + }; + + action.run(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .extracting(DatasetColumn::name).as("Declining the confirmation must change nothing.") + .contains("NAME"); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/DeleteRowsActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/DeleteRowsActionTest.java new file mode 100644 index 00000000..3ed0a98e --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/DeleteRowsActionTest.java @@ -0,0 +1,95 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.eclipse.core.commands.ExecutionException; +import org.eclipse.jface.text.Document; +import org.eclipse.jface.text.IDocument; +import org.eclipse.text.undo.DocumentUndoManagerRegistry; +import org.eclipse.text.undo.IDocumentUndoManager; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link DeleteRowsAction} against the Commands specification: deleting the selected rows in one undo + * step and the rule for the first row. + */ +class DeleteRowsActionTest extends GridActionFixture +{ + @Test + void testDeleteRows_whenTheNextRowLacksAColumnThatAnotherRowHas_changesNothing() + { + final FlatXmlDatasetDocument datasetDocument = + create("" + + "" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0); + + new DeleteRowsAction(context).run(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows()) + .as("Bob would become the first row without an email, which dbUnit would ignore in Carl.") + .hasSize(3); + } + + @Test + void testDeleteRows_withTheSecondAndThirdRowsSelected_deletesBothInOneUndoStep() + throws ExecutionException + { + final IDocument document = new Document( + ""); + DocumentUndoManagerRegistry.connect(document); + final IDocumentUndoManager undoManager = DocumentUndoManagerRegistry.getDocumentUndoManager(document); + undoManager.connect(this); + try + { + final FlatXmlDatasetDocument datasetDocument = create(document); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(1, 2); + final DeleteRowsAction action = new DeleteRowsAction(context); + + action.run(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows()) + .as("Deleting the selected rows must remove both.").hasSize(2); + assertThat(context.multiCellEditTitles) + .as("Delete Rows must report a rejected edit through a modal dialog, not just the " + + "status line.") + .containsExactly("Delete Rows"); + + undoManager.undo(); + datasetDocument.refresh(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows()) + .as("Deleting two rows must be one undo step.").hasSize(4); + } + finally + { + undoManager.disconnect(this); + DocumentUndoManagerRegistry.disconnect(document); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/DeleteTableActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/DeleteTableActionTest.java new file mode 100644 index 00000000..023b16d3 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/DeleteTableActionTest.java @@ -0,0 +1,105 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.eclipse.swt.widgets.Shell; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link DeleteTableAction} against the Commands specification: the table is removed only when the + * user confirms, and the message tells when the DTD declares the table. + */ +class DeleteTableActionTest extends GridActionFixture +{ + @Test + void testDeleteTableConfirmMessage_whenTheTableIsNotDeclaredOnly_hasNoDtdNote() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + + assertThat(DeleteTableAction.confirmMessage(table)) + .as("A table that is not declared-only must not mention the DTD.") + .doesNotContain("DTD"); + } + + @Test + void testDeleteTableConfirmMessage_whenTheTableIsDeclaredOnly_warnsThatDbUnitReadsTheDtd() + { + final FlatXmlDatasetDocument datasetDocument = create( + "\n\n" + + "\n]>\n\n\n"); + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + + assertThat(DeleteTableAction.confirmMessage(table)) + .as("A declared-only table's confirmation must warn that dbUnit reads tables from the " + + "DTD.") + .contains("dbUnit reads tables from the DTD"); + } + + @Test + void testDeleteTable_whenConfirmed_removesTheTable() + { + final FlatXmlDatasetDocument datasetDocument = create( + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + final DeleteTableAction action = new DeleteTableAction(context) + { + @Override + boolean confirmDelete(final Shell shell, final DatasetTable table) + { + assertThat(table.getName()).as("The confirmation must name the active table.") + .isEqualTo("USERS"); + return true; + } + }; + + action.run(); + + assertThat(datasetDocument.getModel().findTable("USERS")) + .as("Confirmed deletion must remove the table.").isEmpty(); + assertThat(datasetDocument.getModel().findTable("ACCOUNTS")) + .as("Deleting one table must not affect the others.").isPresent(); + } + + @Test + void testDeleteTable_whenNotConfirmed_changesNothing() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + final DeleteTableAction action = new DeleteTableAction(context) + { + @Override + boolean confirmDelete(final Shell shell, final DatasetTable table) + { + return false; + } + }; + + action.run(); + + assertThat(datasetDocument.getModel().findTable("USERS")) + .as("Declining the confirmation must change nothing.").isPresent(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/DuplicateRowsActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/DuplicateRowsActionTest.java new file mode 100644 index 00000000..ed7a64a8 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/DuplicateRowsActionTest.java @@ -0,0 +1,59 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.eclipse.swt.graphics.Rectangle; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link DuplicateRowsAction} against the Commands specification: the copies go directly after the + * selected rows, and they are selected. + */ +class DuplicateRowsActionTest extends GridActionFixture +{ + @Test + void testDuplicateRows_withTwoRowsSelected_insertsCopiesDirectlyAfterThemAndSelectsTheNewBlock() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 1); + context.columnIndexes = List.of(0); + final DuplicateRowsAction action = new DuplicateRowsAction(context); + + action.run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows()).as("Duplicating two rows must add two rows.").hasSize(5); + assertThat(table.getRows().get(2).getValue(0)).as("The duplicate block must copy the first row.") + .isEqualTo("1"); + assertThat(table.getRows().get(3).getValue(0)) + .as("The duplicate block must copy the second row, in order.").isEqualTo("2"); + assertThat(context.selectedRegion).as("Duplicate Rows must select the new block.") + .isEqualTo(new Rectangle(0, 2, 1, 2)); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/EditCellInDialogActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/EditCellInDialogActionTest.java new file mode 100644 index 00000000..ea77f51d --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/EditCellInDialogActionTest.java @@ -0,0 +1,48 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link EditCellInDialogAction} against the Commands specification: it opens the anchor cell in a + * dialog editor. + */ +class EditCellInDialogActionTest extends GridActionFixture +{ + @Test + void testEditCellInDialog_run_opensTheAnchorCellInADialogEditor() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.anchorColumnIndex = 0; + context.anchorRowIndex = 0; + final EditCellInDialogAction action = new EditCellInDialogAction(context); + + action.run(); + + assertThat(context.editCellInDialogCalled) + .as("Edit Cell in Dialog must open the anchor cell's editor.").isTrue(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/FillDownActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/FillDownActionTest.java new file mode 100644 index 00000000..c51f2e36 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/FillDownActionTest.java @@ -0,0 +1,220 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.eclipse.swt.graphics.Point; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link FillDownAction} against the Commands specification: the rows below the top row of the + * selection get its value, the enablement does not enumerate the selected cells, and a rejected edit is + * reported by a dialog. + */ +class FillDownActionTest extends GridActionFixture +{ + @Test + void testFillDown_withAMultiRowSelection_copiesTheTopRowIntoTheOtherSelectedRows() + { + final FlatXmlDatasetDocument datasetDocument = create("" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 1, 2); + context.columnIndexes = List.of(1); + context.selectedCellPositions = + List.of(new Point(1, 0), new Point(1, 1), new Point(1, 2)); + final FillDownAction action = new FillDownAction(context); + + action.run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows().get(0).getValue(1)).isEqualTo("Alice"); + assertThat(table.getRows().get(1).getValue(1)).as("Fill Down must copy the top row's value.") + .isEqualTo("Alice"); + assertThat(table.getRows().get(2).getValue(1)).isEqualTo("Alice"); + assertThat(table.getRows().get(0).getValue(0)).as("Fill Down must not touch an unselected column.") + .isEqualTo("1"); + assertThat(context.multiCellEditTitles) + .as("Fill Down must report a rejected edit through a modal dialog, not just the status " + + "line.") + .containsExactly("Fill Down"); + } + + @Test + void testFillDown_withAOneRowSelection_copiesFromTheRowAbove() + { + final FlatXmlDatasetDocument datasetDocument = create("" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(1); + context.columnIndexes = List.of(1); + context.selectedCellPositions = List.of(new Point(1, 1)); + final FillDownAction action = new FillDownAction(context); + + action.run(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows().get(1).getValue(1)) + .as("With a one-row selection, Fill Down must copy from the row above.") + .isEqualTo("Alice"); + assertThat(context.multiCellEditTitles).containsExactly("Fill Down"); + } + + @Test + void testFillDown_whenTheEditWouldEmptyARow_reportsItThroughExecuteMultiCellEditAndChangesNothing() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 1); + context.columnIndexes = List.of(1); + context.selectedCellPositions = List.of(new Point(1, 0), new Point(1, 1)); + final FillDownAction action = new FillDownAction(context); + + action.run(); + + assertThat(context.multiCellEditTitles) + .as("A rejected multi-cell edit must still go through executeMultiCellEdit, naming the " + + "command, so the page can show a modal dialog instead of just the status line.") + .containsExactly("Fill Down"); + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows().get(1).getValue(1)) + .as("A rejected edit must change nothing.").isEqualTo("Bob"); + } + + @Test + void testUpdate_forFillDown_disabledForASingleTopRowSelection() + { + final FlatXmlDatasetDocument datasetDocument = create("" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + final FillDownAction action = new FillDownAction(context); + + context.rowIndexes = List.of(0); + context.columnIndexes = List.of(1); + context.selectedCellPositions = List.of(new Point(1, 0)); + action.update(context.getSelection()); + assertThat(action.isEnabled()) + .as("A single-row selection at the top must disable Fill Down: no row above it.") + .isFalse(); + + context.rowIndexes = List.of(1); + context.selectedCellPositions = List.of(new Point(1, 1)); + action.update(context.getSelection()); + assertThat(action.isEnabled()) + .as("A single-row selection with a row above it must enable Fill Down.").isTrue(); + + context.rowIndexes = List.of(0, 1); + context.selectedCellPositions = List.of(new Point(1, 0), new Point(1, 1)); + action.update(context.getSelection()); + assertThat(action.isEnabled()).as("A multi-row selection must enable Fill Down.").isTrue(); + } + + @Test + void testUpdate_forFillDown_doesNotEnumerateTheSelectedCells() + { + final FlatXmlDatasetDocument datasetDocument = create("" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + final FillDownAction action = new FillDownAction(context); + context.rowIndexes = List.of(0, 1); + context.columnIndexes = List.of(0, 1); + context.selectedCellPositions = + List.of(new Point(0, 0), new Point(0, 1), new Point(1, 0), new Point(1, 1)); + + action.update(context.getSelection()); + + assertThat(action.isEnabled()).as("Two selected rows in a column enable Fill Down.").isTrue(); + assertThat(context.selectedCellPositionReads) + .as("The enablement is worked out on every selection event, so it must read the snapshot " + + "of the selection and not list every selected cell, which is a million for a big " + + "table.") + .isZero(); + } + + @Test + void testUpdate_forFillDown_withOneCellInEachOfTwoColumnsOnTheFirstRow_isDisabled() + { + final FlatXmlDatasetDocument datasetDocument = create("" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + final FillDownAction action = new FillDownAction(context); + context.rowIndexes = List.of(0); + context.columnIndexes = List.of(0, 1); + context.selectedCellPositions = List.of(new Point(0, 0), new Point(1, 0)); + + action.update(context.getSelection()); + + assertThat(action.isEnabled()) + .as("Each column has one selected cell in the first row, so none has a row above it.") + .isFalse(); + } + + @Test + void testUpdate_forFillDown_withCellsOnTheFirstAndSecondRowInDifferentColumns_isEnabled() + { + final FlatXmlDatasetDocument datasetDocument = create("" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + final FillDownAction action = new FillDownAction(context); + context.rowIndexes = List.of(0, 1); + context.columnIndexes = List.of(0, 1); + context.selectedCellPositions = List.of(new Point(0, 0), new Point(1, 1)); + + action.update(context.getSelection()); + + assertThat(action.isEnabled()) + .as("The cell on the second row has the row above it, so its column can be filled.").isTrue(); + } + + @Test + void testFillDown_withANonRectangularSelection_fillsEachColumnFromItsOwnTopSelectedCell() + { + final FlatXmlDatasetDocument datasetDocument = create( + "" + + "" + + "" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 1, 2, 3); + context.columnIndexes = List.of(0, 2); + context.selectedCellPositions = List.of(new Point(0, 0), new Point(0, 1), new Point(0, 2), + new Point(0, 3), new Point(2, 2), new Point(2, 3)); + final FillDownAction action = new FillDownAction(context); + + action.run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows()).extracting(row -> row.getValue(0)) + .as("Column 0 (ID) is selected in every row and must fill from its own top row.") + .containsExactly("1", "1", "1", "1"); + assertThat(table.getRows()).extracting(row -> row.getValue(2)) + .as("Column 2 (CITY) is selected only from row 2 down, so it must fill from row 2, not " + + "row 0, and its unselected rows 0 and 1 must stay untouched.") + .containsExactly("Rome", "Paris", "Cairo", "Cairo"); + assertThat(table.getRows()).extracting(row -> row.getValue(1)) + .as("Column 1 (NAME) has no selected cells at all and must not be touched.") + .containsExactly("Alice", "Bob", "Carol", "Dave"); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionFixture.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionFixture.java new file mode 100644 index 00000000..10d0a7e3 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionFixture.java @@ -0,0 +1,308 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.dbunit.eclipse.dataset.core.dtd.DtdSource; +import org.dbunit.eclipse.dataset.core.edit.DatasetDocument; +import org.dbunit.eclipse.dataset.core.edit.DatasetEditException; +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlOptions; +import org.dbunit.eclipse.dataset.core.model.DatasetRow; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.jface.action.IMenuManager; +import org.eclipse.jface.text.Document; +import org.eclipse.jface.text.IDocument; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +/** + * The fixture of the tests of the actions of the Tables page: a shell for the dialogs and cell editors + * that an action opens, the documents that the actions act on, and a grid context in which a test sets + * the selection and the state of the page, and reads what the action did. + */ +abstract class GridActionFixture +{ + static final String DEFAULTS_DATASET = "\n" + + "\n\n]>\n" + + ""; + + Shell shell; + + @BeforeEach + void createShell() + { + shell = new Shell(Display.getDefault()); + } + + @AfterEach + void disposeShell() + { + shell.dispose(); + } + + static List firstColumnValues(final FlatXmlDatasetDocument datasetDocument, + final String tableKey) + { + final DatasetTable table = datasetDocument.getModel().findTable(tableKey).orElseThrow(); + final List values = new ArrayList<>(); + for (final DatasetRow row : table.getRows()) + { + values.add(row.getValues().get(0)); + } + return values; + } + + static FlatXmlDatasetDocument create(final String content) + { + return create(new Document(content)); + } + + static FlatXmlDatasetDocument create(final IDocument document) + { + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(document, DtdSource.NONE, + FlatXmlOptions.DBUNIT_DEFAULTS, () -> StandardCharsets.UTF_8); + datasetDocument.refresh(); + return datasetDocument; + } + + static FlatXmlDatasetDocument createWithDtdFile(final String content, final String dtdText) + { + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(new Document(content), + (publicId, systemId) -> Optional.of(dtdText), FlatXmlOptions.DBUNIT_DEFAULTS, + () -> StandardCharsets.UTF_8); + datasetDocument.refresh(); + return datasetDocument; + } + + static FlatXmlDatasetDocument createCaseSensitive(final String content) + { + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(new Document(content), + DtdSource.NONE, new FlatXmlOptions(true, FlatXmlOptions.DBUNIT_DEFAULTS.columnSensing()), + () -> StandardCharsets.UTF_8); + datasetDocument.refresh(); + return datasetDocument; + } + + final class TestContext implements DatasetGridContext + { + final DatasetDocument datasetDocument; + + final String tableKey; + + boolean editable = true; + + boolean hasActiveCellEditor; + + final List multiCellEditTitles = new ArrayList<>(); + + int anchorColumnIndex = -1; + + int anchorRowIndex = -1; + + List rowIndexes = List.of(); + + List columnIndexes = List.of(); + + boolean wholeRowsSelected; + + List selectedCellPositions = List.of(); + + int selectedCellPositionReads; + + Text activeCellEditorText; + + boolean selectAllCalled; + + boolean editCellInDialogCalled; + + boolean showInSourceCalled; + + String statusMessage; + + String statusErrorMessage; + + GridSelection staleSelection; + + String clipboardText; + + Rectangle selectedRegion; + + TestContext(final DatasetDocument datasetDocument, final String tableKey) + { + this.datasetDocument = datasetDocument; + this.tableKey = tableKey; + } + + @Override + public DatasetDocument getDatasetDocument() + { + return datasetDocument; + } + + @Override + public boolean isEditable() + { + return editable; + } + + @Override + public String getNullDisplayText() + { + return "(null)"; + } + + @Override + public boolean isDarkTheme() + { + return false; + } + + @Override + public boolean executeEdit(final Runnable edit) + { + if (!editable) + { + return false; + } + try + { + edit.run(); + return true; + } + catch (final DatasetEditException e) + { + return false; + } + } + + @Override + public boolean executeMultiCellEdit(final String title, final Runnable edit) + { + multiCellEditTitles.add(title); + return executeEdit(edit); + } + + @Override + public void fillContextMenu(final IMenuManager menu, final String region) + { + } + + @Override + public boolean hasActiveCellEditor() + { + return hasActiveCellEditor; + } + + @Override + public GridSelection getSelection() + { + if (staleSelection != null) + { + return staleSelection; + } + final DatasetTable table = datasetDocument.getModel().findTable(tableKey).orElseThrow(); + final int rowCount = table.getRows().size(); + final int columnCount = table.getColumns().size(); + return new GridSelection(tableKey, rowCount, columnCount, anchorColumnIndex, anchorRowIndex, + rowIndexes, columnIndexes, wholeRowsSelected); + } + + @Override + public void selectRegion(final int firstColumnIndex, final int firstRowIndex, final int columnCount, + final int rowCount) + { + selectedRegion = new Rectangle(firstColumnIndex, firstRowIndex, columnCount, rowCount); + } + + @Override + public Shell getShell() + { + return shell; + } + + @Override + public Text getActiveCellEditorText() + { + return activeCellEditorText; + } + + @Override + public List getSelectedCellPositions() + { + selectedCellPositionReads++; + return selectedCellPositions; + } + + @Override + public void selectAll() + { + selectAllCalled = true; + } + + @Override + public void editCellInDialog() + { + editCellInDialogCalled = true; + } + + @Override + public void showInSource() + { + showInSourceCalled = true; + } + + @Override + public void setStatusMessage(final String message) + { + statusMessage = message; + } + + @Override + public void setStatusErrorMessage(final String message) + { + statusErrorMessage = message; + } + + @Override + public void writeClipboardText(final String text) + { + clipboardText = text; + } + + @Override + public String readClipboardText() + { + return clipboardText; + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionTest.java new file mode 100644 index 00000000..0ea0f68a --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionTest.java @@ -0,0 +1,229 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.dbunit.eclipse.dataset.core.edit.DatasetEditException; +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.ui.DatasetImages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.jface.resource.ImageDescriptor; +import org.eclipse.jface.text.Document; +import org.eclipse.jface.text.IDocument; +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.Text; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link GridAction}, the base of the actions of the Tables page, against the Commands + * specification: the refresh of the model before a command runs, a command that does nothing while a cell + * editor is open, the report of a rejected edit or of a table that is gone as the error message, the + * enablement of the actions on a read-only page, and the icons of the actions. + */ +class GridActionTest extends GridActionFixture +{ + @Test + void testRun_whileACellEditorIsActive_changesNothing() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.hasActiveCellEditor = true; + final InsertRowBelowAction action = new InsertRowBelowAction(context); + + action.run(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows()) + .as("Insert Row Below must do nothing while a cell editor is active.").hasSize(1); + } + + @Test + void testRun_whenTheTextChangedAndNothingRefreshedTheDocument_refreshesItBeforeTheCommandRuns() + throws Exception + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + final TestContext context = new TestContext(datasetDocument, "USERS"); + final List staleWhenTheCommandRan = new ArrayList<>(); + final GridAction action = new GridAction(context) + { + @Override + protected void runOnGrid(final DatasetGridContext gridContext) + { + staleWhenTheCommandRan.add(datasetDocument.isStale()); + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return true; + } + }; + document.replace(document.get().indexOf(""); + + action.run(); + + assertThat(staleWhenTheCommandRan) + .as("A command that is queued behind a change of the text must run on the model of the " + + "text as it is now.") + .containsExactly(false); + } + + @Test + void testRun_whileACellEditorIsActiveAndTheTextChanged_doesNotRefreshTheDocument() throws Exception + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.hasActiveCellEditor = true; + final Text text = new Text(shell, SWT.NONE); + text.setText("hello world"); + text.setSelection(0, 5); + context.activeCellEditorText = text; + document.replace(document.get().indexOf(""); + + new CopyAction(context).run(); + + assertThat(datasetDocument.isStale()) + .as("A refresh rebuilds the grids and can close the editor, so a command that works on " + + "the editor's text must leave it to the page.") + .isTrue(); + } + + @Test + void testRun_whenRunOnGridThrowsADatasetEditException_reportsItAsAnErrorMessageInsteadOfPropagating() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + final GridAction action = new GridAction(context) + { + @Override + protected void runOnGrid(final DatasetGridContext context) + { + throw new DatasetEditException("The selection is out of date."); + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return true; + } + }; + + action.run(); + + assertThat(context.statusErrorMessage) + .as("A DatasetEditException thrown directly by runOnGrid must be reported as the status " + + "line's error message, like a rejected edit, instead of propagating.") + .isEqualTo("The selection is out of date."); + assertThat(context.statusMessage) + .as("The rejection must not be reported as a normal message, which an earlier error " + + "message would hide.") + .isNull(); + } + + @Test + void testRun_whenTheSelectionNamesATableThatNoLongerExists_reportsItAsAnErrorMessage() + { + final List> actionsOfASelectedTable = + List.of(AddColumnAction::new, CopyAction::new, DeleteColumnAction::new, DeleteTableAction::new, + FillDownAction::new, PasteAction::new, RenameColumnAction::new, RenameTableAction::new, + SetEmptyStringAction::new, SetNullAction::new); + final Map errorMessages = new LinkedHashMap<>(); + + for (final Function newAction : actionsOfASelectedTable) + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.staleSelection = new GridSelection("GONE", 1, 1, 0, 0, List.of(0), List.of(0), false); + context.clipboardText = "X"; + final GridAction action = newAction.apply(context); + + action.run(); + + errorMessages.put(action.getClass().getSimpleName(), context.statusErrorMessage); + } + + assertThat(errorMessages).as("Each action that needs the selected table must have been run.") + .hasSize(actionsOfASelectedTable.size()) + .allSatisfy((actionName, errorMessage) -> assertThat(errorMessage) + .as("A selection that names a table missing from the model must be reported as the " + + "status line's error message by " + actionName + ".") + .isEqualTo("There is no table 'GONE'.")); + } + + @Test + void testUpdate_onAReadOnlyPage_enablesCopyAndSelectAllButNotCut() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0); + context.columnIndexes = List.of(0); + context.editable = false; + final List actions = + List.of(new CopyAction(context), new SelectAllAction(context), new CutAction(context)); + final List enabled = new ArrayList<>(); + for (final GridAction action : actions) + { + action.update(context.getSelection()); + enabled.add(action.isEnabled()); + } + + assertThat(enabled).as("A read-only page must still copy and select cells, but not cut them.") + .containsExactly(true, true, false); + } + + @Test + void testImageDescriptor_ofEachActionWithAGeneratedIcon_isThatIcon() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + + final List actionImages = List.of( + new InsertRowAboveAction(context).getImageDescriptor(), + new InsertRowBelowAction(context).getImageDescriptor(), + new DuplicateRowsAction(context).getImageDescriptor(), + new DeleteRowsAction(context).getImageDescriptor(), + new AddColumnAction(context).getImageDescriptor(), + new DeleteColumnAction(context).getImageDescriptor(), + new AddTableAction(context).getImageDescriptor(), + new SetNullAction(context).getImageDescriptor(), + new FillDownAction(context).getImageDescriptor()); + + assertThat(actionImages).as("Every command with a generated icon must show it in toolbars and menus.") + .containsExactly(DatasetImages.getImageDescriptor(DatasetImages.IMG_INSERT_ROW_ABOVE), + DatasetImages.getImageDescriptor(DatasetImages.IMG_INSERT_ROW_BELOW), + DatasetImages.getImageDescriptor(DatasetImages.IMG_DUPLICATE_ROWS), + DatasetImages.getImageDescriptor(DatasetImages.IMG_DELETE_ROWS), + DatasetImages.getImageDescriptor(DatasetImages.IMG_ADD_COLUMN), + DatasetImages.getImageDescriptor(DatasetImages.IMG_DELETE_COLUMN), + DatasetImages.getImageDescriptor(DatasetImages.IMG_ADD_TABLE), + DatasetImages.getImageDescriptor(DatasetImages.IMG_SET_NULL), + DatasetImages.getImageDescriptor(DatasetImages.IMG_FILL_DOWN)); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/InsertRowActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/InsertRowActionTest.java new file mode 100644 index 00000000..f2b7a1d7 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/InsertRowActionTest.java @@ -0,0 +1,217 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.eclipse.swt.graphics.Rectangle; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link InsertRowAboveAction} and {@link InsertRowBelowAction}, which share {@link InsertRowAction}, + * against the Commands specification: where the new row goes, which cells it fills, and the rule for the + * first row. + */ +class InsertRowActionTest extends GridActionFixture +{ + @Test + void testInsertRowBelow_withAnchorInTheSecondRow_insertsAtIndex2AndSelectsTheNewRow() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.anchorColumnIndex = 0; + context.anchorRowIndex = 1; + final InsertRowBelowAction action = new InsertRowBelowAction(context); + + action.run(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows()) + .as("Insert Row Below must add one row.").hasSize(4); + assertThat(context.selectedRegion) + .as("Insert Row Below must select the new row at index 2, in the anchor column.") + .isEqualTo(new Rectangle(0, 2, 1, 1)); + } + + @Test + void testInsertRowBelow_withAnchorInTheFirstOfThreeRows_putsTheRowSecond() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.anchorColumnIndex = 0; + context.anchorRowIndex = 0; + + new InsertRowBelowAction(context).run(); + + assertThat(firstColumnValues(datasetDocument, "USERS")) + .as("The anchor in the first row is an anchor like any other: the new row goes right below " + + "it.") + .containsExactly("1", "", "2", "3"); + } + + @Test + void testInsertRowBelow_withNoAnchor_appendsTheRowAfterTheLastRow() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + final InsertRowBelowAction action = new InsertRowBelowAction(context); + + action.run(); + + assertThat(firstColumnValues(datasetDocument, "USERS")) + .as("With nothing to insert below, the new row must go after the last row, not before the " + + "first.") + .containsExactly("1", "2", "3", ""); + assertThat(context.selectedRegion).as("The new, last row must be selected.") + .isEqualTo(new Rectangle(0, 3, 1, 1)); + } + + @Test + void testInsertRowAbove_withNoAnchor_putsTheRowBeforeTheFirstRow() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + final InsertRowAboveAction action = new InsertRowAboveAction(context); + + action.run(); + + assertThat(firstColumnValues(datasetDocument, "USERS")) + .as("With nothing to insert above, the new row must go before the first row.") + .containsExactly("", "1", "2", "3"); + } + + @Test + void testInsertRowBelow_withAnchorInTheFirstRow_getsTheEmptyStringInItsFirstColumn() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.anchorColumnIndex = 0; + context.anchorRowIndex = 0; + final InsertRowBelowAction action = new InsertRowBelowAction(context); + + action.run(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows().get(1) + .getValues()) + .as("A blank row must get the empty string in its first column, not null, so the " + + "insert does not hit the all-null-row rejection.") + .containsExactly("", null); + } + + @Test + void testInsertRowBelow_withAnchorInTheFirstRow_showsNoStatusMessage() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.anchorColumnIndex = 0; + context.anchorRowIndex = 0; + + new InsertRowBelowAction(context).run(); + + assertThat(context.statusMessage).as("A row after the first row needs no explanation.").isNull(); + } + + @Test + void testInsertRowAbove_withAnchorInTheFirstRowWithoutDtd_getsEveryColumnOfTheOldFirstRow() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.anchorColumnIndex = 0; + context.anchorRowIndex = 0; + final InsertRowAboveAction action = new InsertRowAboveAction(context); + + action.run(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows().get(0) + .getValues()) + .as("The new first row needs every column of the old first row, or dbUnit would ignore " + + "the names of the other rows.") + .containsExactly("", ""); + assertThat(context.statusMessage) + .as("The user must be told why the new row has an empty string in every column.") + .isEqualTo("The new row is the table's first row now, so it has an empty string in each " + + "column of the old first row: dbUnit takes a table's columns from its first row."); + assertThat(context.selectedRegion).as("The new row must be selected, in the anchor column.") + .isEqualTo(new Rectangle(0, 0, 1, 1)); + } + + @Test + void testInsertRowAbove_withAnchorInTheSecondRow_getsTheEmptyStringInItsFirstColumnOnly() + { + final FlatXmlDatasetDocument datasetDocument = create( + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.anchorColumnIndex = 0; + context.anchorRowIndex = 1; + + new InsertRowAboveAction(context).run(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows().get(1) + .getValues()) + .as("A blank row must get the empty string in its first column, not null, so the " + + "insert does not hit the all-null-row rejection.") + .containsExactly("", null); + assertThat(context.statusMessage).as("A row after the first row needs no explanation.").isNull(); + } + + @Test + void testInsertRowAbove_withAnchorInTheFirstRowOfATableWithADtd_getsTheEmptyStringInItsFirstColumnOnly() + { + final FlatXmlDatasetDocument datasetDocument = + create("\n" + + "\n]>\n" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.anchorColumnIndex = 0; + context.anchorRowIndex = 0; + + new InsertRowAboveAction(context).run(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows().get(0) + .getValues()) + .as("With a DTD, dbUnit takes the columns from the DTD, so the first column is enough.") + .containsExactly("", null); + assertThat(context.statusMessage).as("There is nothing to explain.").isNull(); + } + + @Test + void testUpdate_whenThePageGoesFromEditableToReadOnly_enablesThenDisablesTheAction() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + final InsertRowAboveAction action = new InsertRowAboveAction(context); + + action.update(context.getSelection()); + assertThat(action.isEnabled()) + .as("An editable page with a column must enable Insert Row Above.").isTrue(); + + context.editable = false; + action.update(context.getSelection()); + assertThat(action.isEnabled()).as("A read-only page must disable the action.").isFalse(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/MoveRowsActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/MoveRowsActionTest.java new file mode 100644 index 00000000..719a5d12 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/MoveRowsActionTest.java @@ -0,0 +1,145 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.eclipse.swt.graphics.Rectangle; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link MoveRowsUpAction} and {@link MoveRowsDownAction}, which share {@link MoveRowsAction}, + * against the Commands specification: moving a block of rows, the selection that stays on it, the rule + * for the first row, and when the actions are enabled. + */ +class MoveRowsActionTest extends GridActionFixture +{ + @Test + void testMoveRowsUp_whenTheRowMovedToTheTopLacksAColumnThatAnotherRowHas_changesNothing() + { + final FlatXmlDatasetDocument datasetDocument = + create("" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(1); + + new MoveRowsUpAction(context).run(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows().get(0).getValue(1)) + .as("Alice must stay the first row, because Bob lacks the email that she has.") + .isEqualTo("Alice"); + assertThat(context.selectedRegion).as("A move that was refused must not move the selection.").isNull(); + } + + @Test + void testMoveRowsUp_withAContiguousBlockSelected_movesItUpAndKeepsItSelected() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(1, 2); + context.columnIndexes = List.of(0); + final MoveRowsUpAction action = new MoveRowsUpAction(context); + + action.run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows().get(0).getValue(0)).as("Moving the block up must place it first.") + .isEqualTo("2"); + assertThat(table.getRows().get(1).getValue(0)).isEqualTo("3"); + assertThat(table.getRows().get(2).getValue(0)) + .as("The row the block moved past must follow it.").isEqualTo("1"); + assertThat(context.selectedRegion).as("Move Rows Up must keep the moved block selected.") + .isEqualTo(new Rectangle(0, 0, 1, 2)); + } + + @Test + void testMoveRowsDown_withAContiguousBlockSelected_movesItDownAndKeepsItSelected() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 1); + context.columnIndexes = List.of(0); + final MoveRowsDownAction action = new MoveRowsDownAction(context); + + action.run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows().get(0).getValue(0)) + .as("The row the block moved past must precede it.").isEqualTo("3"); + assertThat(table.getRows().get(1).getValue(0)).as("Moving the block down must place it after.") + .isEqualTo("1"); + assertThat(table.getRows().get(2).getValue(0)).isEqualTo("2"); + assertThat(context.selectedRegion).as("Move Rows Down must keep the moved block selected.") + .isEqualTo(new Rectangle(0, 1, 1, 2)); + } + + @Test + void testMoveRowsDown_withSeveralColumnsOfARowSelected_keepsThoseColumnsSelected() + { + final FlatXmlDatasetDocument datasetDocument = create("" + + ""); + final TestContext context = new TestContext(datasetDocument, "T"); + context.rowIndexes = List.of(0); + context.columnIndexes = List.of(1, 2); + + new MoveRowsDownAction(context).run(); + + assertThat(context.selectedRegion) + .as("The columns that were selected must stay selected in the row that moved.") + .isEqualTo(new Rectangle(1, 1, 2, 1)); + } + + @Test + void testUpdate_forMoveRowsUpAndDown_disabledWhenNotContiguousOrAtTheEdge() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + final MoveRowsUpAction up = new MoveRowsUpAction(context); + final MoveRowsDownAction down = new MoveRowsDownAction(context); + + context.rowIndexes = List.of(0, 2); + up.update(context.getSelection()); + down.update(context.getSelection()); + assertThat(up.isEnabled()).as("A non-contiguous selection must disable Move Rows Up.").isFalse(); + assertThat(down.isEnabled()).as("A non-contiguous selection must disable Move Rows Down.") + .isFalse(); + + context.rowIndexes = List.of(0, 1); + up.update(context.getSelection()); + down.update(context.getSelection()); + assertThat(up.isEnabled()).as("A block already at the top must disable Move Rows Up.").isFalse(); + assertThat(down.isEnabled()).as("A block not at the bottom must enable Move Rows Down.").isTrue(); + + context.rowIndexes = List.of(1, 2); + up.update(context.getSelection()); + down.update(context.getSelection()); + assertThat(up.isEnabled()).as("A block not at the top must enable Move Rows Up.").isTrue(); + assertThat(down.isEnabled()).as("A block already at the bottom must disable Move Rows Down.") + .isFalse(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/PasteActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/PasteActionTest.java new file mode 100644 index 00000000..6c74d80f --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/PasteActionTest.java @@ -0,0 +1,519 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import org.dbunit.eclipse.dataset.core.edit.ChangeOrigin; +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.eclipse.core.commands.ExecutionException; +import org.eclipse.jface.text.Document; +import org.eclipse.jface.text.IDocument; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.text.undo.DocumentUndoManagerRegistry; +import org.eclipse.text.undo.IDocumentUndoManager; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link PasteAction} against the Clipboard specification: pasting a block, a single value, and a + * text with blank lines, into existing rows and into a table that has no rows, and what is pasted after + * a copy. + */ +class PasteActionTest extends GridActionFixture +{ + @Test + void testCopyThenPaste_ofNonAdjacentRowsOntoTheLastRow_pastesThemOneAfterTheOther() + { + final FlatXmlDatasetDocument datasetDocument = create("" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 2); + context.columnIndexes = List.of(0, 1); + context.wholeRowsSelected = true; + context.selectedCellPositions = + List.of(new Point(0, 0), new Point(1, 0), new Point(0, 2), new Point(1, 2)); + new CopyAction(context).run(); + context.rowIndexes = List.of(2); + context.columnIndexes = List.of(0); + context.wholeRowsSelected = false; + context.selectedCellPositions = List.of(new Point(0, 2)); + + new PasteAction(context).run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows()).extracting(row -> row.getValues()) + .as("The first copied row must overwrite the last row and the second must be appended, " + + "with no empty row between them.") + .containsExactly(List.of("1", "Alice"), List.of("2", "Bob"), List.of("1", "Alice"), + List.of("3", "Carol")); + assertThat(context.statusErrorMessage).as("Nothing may be rejected.").isNull(); + } + + @Test + void testCopyThenPaste_ofNonAdjacentCellsOfOneColumn_writesNoNullBetweenTheTargets() + { + final FlatXmlDatasetDocument datasetDocument = create("" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 2); + context.columnIndexes = List.of(0); + context.selectedCellPositions = List.of(new Point(0, 0), new Point(0, 2)); + new CopyAction(context).run(); + context.rowIndexes = List.of(0); + context.columnIndexes = List.of(1); + context.selectedCellPositions = List.of(new Point(1, 0)); + + new PasteAction(context).run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows()).extracting(row -> row.getValue(1)) + .as("The two copied cells must go into the next two cells of the target column, and the " + + "third cell must stay as it was.") + .containsExactly("1", "3", "Carol"); + } + + @Test + void testCopyThenPaste_ofAnEmptyStringCell_preservesItAsEmptyStringNotNull() + { + final FlatXmlDatasetDocument datasetDocument = create( + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0); + context.columnIndexes = List.of(1); + context.selectedCellPositions = List.of(new Point(1, 0)); + new SetEmptyStringAction(context).run(); + + new CopyAction(context).run(); + context.rowIndexes = List.of(1); + context.selectedCellPositions = List.of(new Point(1, 1)); + new PasteAction(context).run(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows().get(1).getValue(1)) + .as("Pasting a copied empty-string cell must set the empty string, not NULL.") + .isEqualTo(""); + } + + @Test + void testPaste_ofABlockAtTheLastRow_appendsOneRowInOneUndoStep() throws ExecutionException + { + final IDocument document = new Document(""); + DocumentUndoManagerRegistry.connect(document); + final IDocumentUndoManager undoManager = DocumentUndoManagerRegistry.getDocumentUndoManager(document); + undoManager.connect(this); + try + { + final FlatXmlDatasetDocument datasetDocument = create(document); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0); + context.columnIndexes = List.of(0, 1); + context.clipboardText = "10\tX\n20\tY\n30\tZ\n"; + final PasteAction action = new PasteAction(context); + final List modelRebuilds = new ArrayList<>(); + datasetDocument.addModelListener(event -> modelRebuilds.add(event.origin())); + + action.run(); + + assertThat(modelRebuilds) + .as("A paste that changes a row and appends rows must rebuild the model once.") + .containsExactly(ChangeOrigin.EDIT); + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows()).as("A 3-row paste at the last row must append two rows.") + .hasSize(3); + assertThat(table.getRows().get(0).getValue(0)).isEqualTo("10"); + assertThat(table.getRows().get(0).getValue(1)).isEqualTo("X"); + assertThat(table.getRows().get(1).getValue(0)).isEqualTo("20"); + assertThat(table.getRows().get(1).getValue(1)).isEqualTo("Y"); + assertThat(table.getRows().get(2).getValue(0)).isEqualTo("30"); + assertThat(table.getRows().get(2).getValue(1)).isEqualTo("Z"); + assertThat(context.selectedRegion).as("Paste must select the pasted block.") + .isEqualTo(new Rectangle(0, 0, 2, 3)); + + undoManager.undo(); + datasetDocument.refresh(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows()) + .as("The existing-row change and the appended rows must be one undo step.").hasSize(1); + assertThat(context.multiCellEditTitles) + .as("Paste must report a rejected edit through a modal dialog, not just the status " + + "line.") + .containsExactly("Paste"); + } + finally + { + undoManager.disconnect(this); + DocumentUndoManagerRegistry.disconnect(document); + } + } + + @Test + void testPaste_ofASingleValueOntoAMultiCellSelection_fillsEverySelectedCell() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 1); + context.columnIndexes = List.of(1); + context.selectedCellPositions = List.of(new Point(1, 0), new Point(1, 1)); + context.clipboardText = "X"; + final PasteAction action = new PasteAction(context); + + action.run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows().get(0).getValue(1)) + .as("A single pasted value onto a multi-cell selection must fill every selected cell.") + .isEqualTo("X"); + assertThat(table.getRows().get(1).getValue(1)).isEqualTo("X"); + assertThat(context.multiCellEditTitles).containsExactly("Paste"); + } + + @Test + void testPaste_ofASingleValueOntoAMultiCellSelection_listsTheSelectedCellsOnce() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 1); + context.columnIndexes = List.of(1); + context.selectedCellPositions = List.of(new Point(1, 0), new Point(1, 1)); + context.clipboardText = "X"; + + new PasteAction(context).run(); + + assertThat(context.selectedCellPositionReads) + .as("The cells to fill are listed once, to fill them, and not again to find out that there " + + "are several.") + .isEqualTo(1); + } + + @Test + void testPaste_ofASingleValueOntoTwoCellsOfOneRow_fillsBothCells() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(1); + context.columnIndexes = List.of(0, 1); + context.selectedCellPositions = List.of(new Point(0, 1), new Point(1, 1)); + context.clipboardText = "X"; + + new PasteAction(context).run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows()).extracting(row -> row.getValues()) + .as("Two selected cells in one row are several cells, so the value must fill both of them " + + "and leave the other row alone.") + .containsExactly(List.of("1", "Alice"), List.of("X", "X")); + } + + @Test + void testPaste_ofASingleValueOntoOneCell_pastesItWithoutListingTheSelectedCells() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(1); + context.columnIndexes = List.of(1); + context.selectedCellPositions = List.of(new Point(1, 1)); + context.clipboardText = "X"; + + new PasteAction(context).run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows()).extracting(row -> row.getValues()) + .as("The value must go into the one selected cell.") + .containsExactly(List.of("1", "Alice"), List.of("2", "X")); + assertThat(context.selectedCellPositionReads) + .as("One cell is the anchor of a block, and the snapshot of the selection says so, so " + + "the paste must not list the selected cells.") + .isZero(); + } + + @Test + void testPaste_ofABlockOntoAMultiCellSelection_pastesItAtTheAnchorWithoutListingTheSelectedCells() + { + final FlatXmlDatasetDocument datasetDocument = create("" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 1, 2); + context.columnIndexes = List.of(0, 1); + context.selectedCellPositions = List.of(new Point(0, 0), new Point(0, 1), new Point(0, 2), + new Point(1, 0), new Point(1, 1), new Point(1, 2)); + context.clipboardText = "10\tX\n20\tY\n"; + + new PasteAction(context).run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows()).extracting(row -> row.getValues()) + .as("The block must go in at the top-left cell of the selection, and the third row stays.") + .containsExactly(List.of("10", "X"), List.of("20", "Y"), List.of("3", "Carol")); + assertThat(context.selectedCellPositionReads) + .as("A block goes in at the anchor, so the paste must read the snapshot of the selection " + + "and not list every selected cell, which is a million for a big table.") + .isZero(); + } + + @Test + void testPaste_withMorePastedColumnsThanFit_ignoresThemWithAStatusMessage() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0); + context.columnIndexes = List.of(0); + context.clipboardText = "10\tX\n"; + final PasteAction action = new PasteAction(context); + + action.run(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows().get(0).getValue(0)) + .as("A column that fits must still be pasted.").isEqualTo("10"); + assertThat(context.statusMessage) + .as("A pasted column beyond the table's last column must be ignored with a status message.") + .isEqualTo("Ignored 1 pasted column beyond the table's last column."); + } + + @Test + void testPaste_withNoValidAnchorOnANonEmptyTable_doesNothing() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.clipboardText = "X\tY\n"; + final PasteAction action = new PasteAction(context); + + action.run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows()) + .as("A paste with no valid anchor on a non-empty table must do nothing, not overwrite " + + "row 0.") + .hasSize(1); + assertThat(table.getRows().get(0).getValue(0)).isEqualTo("1"); + assertThat(table.getRows().get(0).getValue(1)).isEqualTo("Alice"); + } + + @Test + void testPaste_forAnEmptyTable_isEnabledOnlyWithColumns() + { + final FlatXmlDatasetDocument withColumns = create( + "\n\n" + + "\n]>\n\n\n"); + final TestContext withColumnsContext = new TestContext(withColumns, "USERS"); + final PasteAction withColumnsAction = new PasteAction(withColumnsContext); + + withColumnsAction.update(withColumnsContext.getSelection()); + assertThat(withColumnsAction.isEnabled()) + .as("An empty table with columns must enable Paste whatever the clipboard holds.") + .isTrue(); + + final FlatXmlDatasetDocument withoutColumns = create(""); + final TestContext withoutColumnsContext = new TestContext(withoutColumns, "NONE"); + withoutColumnsContext.clipboardText = "1\n"; + final PasteAction withoutColumnsAction = new PasteAction(withoutColumnsContext); + + withoutColumnsAction.update(withoutColumnsContext.getSelection()); + assertThat(withoutColumnsAction.isEnabled()) + .as("A table without columns must stay disabled for Paste even with clipboard text.") + .isFalse(); + } + + @Test + void testPaste_forAnEmptyTableWithAnEmptyClipboard_doesNothing() + { + final FlatXmlDatasetDocument datasetDocument = create( + "\n\n" + + "\n]>\n\n\n"); + final TestContext context = new TestContext(datasetDocument, "USERS"); + final PasteAction action = new PasteAction(context); + + action.run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows()).as("Paste with an empty clipboard must not add a row.").isEmpty(); + } + + @Test + void testPaste_of2x2BlockIntoAnEmpty3ColumnTable_appendsTwoRowsFromColumnZeroInOneUndoStep() + throws ExecutionException + { + final IDocument document = new Document( + "\n\n" + + "\n]>\n" + + "\n\n"); + DocumentUndoManagerRegistry.connect(document); + final IDocumentUndoManager undoManager = DocumentUndoManagerRegistry.getDocumentUndoManager(document); + undoManager.connect(this); + try + { + final FlatXmlDatasetDocument datasetDocument = create(document); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.clipboardText = "1\tAlice\n2\tBob\n"; + final PasteAction action = new PasteAction(context); + final List modelRebuilds = new ArrayList<>(); + datasetDocument.addModelListener(event -> modelRebuilds.add(event.origin())); + + action.run(); + + assertThat(modelRebuilds).as("Pasting into an empty table must rebuild the model once.") + .containsExactly(ChangeOrigin.EDIT); + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows()).as("The paste must append two rows.").hasSize(2); + assertThat(table.getRows().get(0).getValue(0)).isEqualTo("1"); + assertThat(table.getRows().get(0).getValue(1)).isEqualTo("Alice"); + assertThat(table.getRows().get(1).getValue(0)).isEqualTo("2"); + assertThat(table.getRows().get(1).getValue(1)).isEqualTo("Bob"); + assertThat(context.selectedRegion).as("Paste must select the pasted block.") + .isEqualTo(new Rectangle(0, 0, 2, 2)); + + undoManager.undo(); + datasetDocument.refresh(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows()) + .as("Appending the pasted rows must be one undo step.").isEmpty(); + } + finally + { + undoManager.disconnect(this); + DocumentUndoManagerRegistry.disconnect(document); + } + } + + @Test + void testPaste_ofABlankLineBetweenRowsToAppend_skipsItAndAppendsTheOtherRows() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0); + context.columnIndexes = List.of(0, 1); + context.clipboardText = "10\tX\n\n30\tZ\n"; + final PasteAction action = new PasteAction(context); + + action.run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows()).extracting(row -> row.getValues()) + .as("A pasted blank line cannot become a row, so the rows around it must be pasted without " + + "it.") + .containsExactly(List.of("10", "X"), List.of("30", "Z")); + assertThat(context.selectedRegion).as("Paste must select the rows it wrote.") + .isEqualTo(new Rectangle(0, 0, 2, 2)); + assertThat(context.statusMessage).as("Paste must say that it skipped a row.") + .isEqualTo("Skipped 1 pasted row without values, because a new row needs at least one " + + "value."); + } + + @Test + void testPaste_ofTextEndingInABlankLineIntoAnEmptyTable_appendsTheRowsAndSkipsTheBlankLine() + { + final FlatXmlDatasetDocument datasetDocument = create( + "\n\n" + + "\n]>\n" + + "\n\n"); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.clipboardText = "1\tAlice\n2\tBob\n\n"; + final PasteAction action = new PasteAction(context); + + action.run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows()).extracting(row -> row.getValues()) + .as("A trailing blank line must not fail the paste of the rows before it.") + .containsExactly(List.of("1", "Alice"), List.of("2", "Bob")); + assertThat(context.selectedRegion).as("Paste must select the rows it wrote, not the skipped one.") + .isEqualTo(new Rectangle(0, 0, 2, 2)); + assertThat(context.statusMessage).as("Paste must say that it skipped a row.") + .isEqualTo("Skipped 1 pasted row without values, because a new row needs at least one " + + "value."); + } + + @Test + void testPaste_whenEveryRowToAppendHasNoValue_changesNothingAndSaysWhy() + { + final FlatXmlDatasetDocument datasetDocument = create( + "\n\n" + + "\n]>\n" + + "\n\n"); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.clipboardText = "\n\n"; + final PasteAction action = new PasteAction(context); + + action.run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows()).as("Blank lines must not add rows.").isEmpty(); + assertThat(context.multiCellEditTitles).as("A paste with nothing to write must not run an edit.") + .isEmpty(); + assertThat(context.selectedRegion).as("A paste that wrote nothing must not select anything.") + .isNull(); + assertThat(context.statusMessage).as("Paste must say why nothing was added.") + .isEqualTo("Skipped 2 pasted rows without values, because a new row needs at least one " + + "value."); + } + + @Test + void testPaste_whenOnlyValuesBeyondTheLastColumnWouldMakeUpARowToAppend_reportsBothOmissions() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0); + context.columnIndexes = List.of(1); + context.clipboardText = "A\tB\n\tD\n"; + final PasteAction action = new PasteAction(context); + + action.run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows()).extracting(row -> row.getValues()) + .as("The value that fits must be pasted, and the row left without values must be skipped.") + .containsExactly(List.of("1", "A")); + assertThat(context.statusMessage).as("Paste must report the ignored column and the skipped row.") + .isEqualTo("Ignored 1 pasted column beyond the table's last column. Skipped 1 pasted row " + + "without values, because a new row needs at least one value."); + } + + @Test + void testPaste_ofACopiedNullCellOntoAnExistingCell_setsItToNull() + { + final FlatXmlDatasetDocument datasetDocument = create( + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(1); + context.columnIndexes = List.of(1); + context.clipboardText = "\n"; + final PasteAction action = new PasteAction(context); + + action.run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows().get(1).getValues()) + .as("A blank line is a NULL cell, which Copy writes for one, so Paste must set the target " + + "cell to NULL.") + .containsExactly("2", null); + assertThat(context.statusMessage).as("Pasting onto an existing row skips nothing.").isNull(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/RenameColumnActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/RenameColumnActionTest.java new file mode 100644 index 00000000..75385b31 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/RenameColumnActionTest.java @@ -0,0 +1,117 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.eclipse.jface.dialogs.IInputValidator; +import org.eclipse.swt.widgets.Shell; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link RenameColumnAction} against the Commands specification: the anchor column gets the new + * name, a name that another column has in another letter case counts as used, and the dialog tells when + * the DTD declares the column. + */ +class RenameColumnActionTest extends GridActionFixture +{ + @Test + void testRenameColumnNameDialogMessage_whenTheColumnIsNotDeclared_isJustThePrompt() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final DatasetColumn column = + datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns().get(0); + + assertThat(RenameColumnAction.nameDialogMessage(column)) + .as("A column that is not DTD-declared must get a plain prompt.") + .isEqualTo("Column name:"); + } + + @Test + void testRenameColumnNameDialogMessage_whenTheColumnIsDeclared_warnsThatDbUnitReadsTheDtd() + { + final FlatXmlDatasetDocument datasetDocument = create( + "\n\n" + + "\n]>\n\n \n" + + "\n"); + final DatasetColumn column = + datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns().get(0); + + assertThat(RenameColumnAction.nameDialogMessage(column)) + .as("A DTD-declared column must warn that dbUnit reads columns from the DTD.") + .contains("dbUnit reads a flat XML dataset's columns from its DTD"); + } + + @Test + void testRenameColumn_whenTheDialogReturnsANewName_renamesTheAnchorColumn() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.anchorColumnIndex = 0; + final RenameColumnAction action = new RenameColumnAction(context) + { + @Override + String openNameDialog(final Shell shell, final String currentName, + final IInputValidator validator, final DatasetColumn column) + { + return "USER_ID"; + } + }; + + action.run(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns().get(0).name()) + .as("Rename Column must apply the entered name.").isEqualTo("USER_ID"); + } + + @Test + void testRenameColumn_whenAnotherColumnIsEqualIgnoringCaseYetDistinct_countsItsNameAsUsed() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.anchorColumnIndex = 1; + final List validators = new ArrayList<>(); + final RenameColumnAction action = new RenameColumnAction(context) + { + @Override + String openNameDialog(final Shell shell, final String currentName, + final IInputValidator validator, final DatasetColumn column) + { + validators.add(validator); + return null; + } + }; + + action.run(); + + assertThat(validators).as("Rename Column must open its dialog.").hasSize(1); + assertThat(validators.get(0).isValid("ID")) + .as("The name of the other column must count as used, although it equals the renamed " + + "column's name ignoring case.") + .isNotNull(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/RenameTableActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/RenameTableActionTest.java new file mode 100644 index 00000000..3c4a5576 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/RenameTableActionTest.java @@ -0,0 +1,188 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.eclipse.jface.dialogs.IInputValidator; +import org.eclipse.swt.widgets.Shell; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link RenameTableAction} against the Commands specification: the table gets the new name and the + * internal subset follows it, a rejected edit changes nothing, the dialog refuses a name that another + * table has, and the dialog tells when a DTD declares the table. + */ +class RenameTableActionTest extends GridActionFixture +{ + @Test + void testRenameTableNameDialogMessage_whenTheTableIsNotDeclaredOnly_isJustThePrompt() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + + assertThat(RenameTableAction.nameDialogMessage(table)) + .as("A table that is not declared-only must get a plain prompt.") + .isEqualTo("Table name:"); + } + + @Test + void testRenameTableNameDialogMessage_whenTheTableIsDeclaredOnly_warnsThatDbUnitReadsTheDtd() + { + final FlatXmlDatasetDocument datasetDocument = create( + "\n\n" + + "\n]>\n\n\n"); + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + + assertThat(RenameTableAction.nameDialogMessage(table)) + .as("A declared-only table must warn that dbUnit reads tables from the DTD.") + .contains("dbUnit reads a flat XML dataset's tables from its DTD"); + } + + @Test + void testRenameTableNameDialogMessage_whenADtdFileDeclaresTheTable_warnsToRenameItThereToo() + { + final FlatXmlDatasetDocument datasetDocument = createWithDtdFile( + "", + ""); + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + + assertThat(RenameTableAction.nameDialogMessage(table)) + .as("The editor does not change a DTD file, so the dialog must say that the file needs the " + + "new name too.") + .contains("a DTD file declares this table", "rename the table there too"); + } + + @Test + void testRenameTableNameDialogMessage_whenOnlyTheInternalSubsetDeclaresTheTable_isJustThePrompt() + { + final FlatXmlDatasetDocument datasetDocument = create("\n\n" + + "\n]>\n\n"); + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + + assertThat(RenameTableAction.nameDialogMessage(table)) + .as("The editor renames a table in the internal subset itself, so there is nothing to warn " + + "about.") + .isEqualTo("Table name:"); + } + + @Test + void testRenameTable_whenTheInternalSubsetDeclaresTheTable_theDtdStillDeclaresTheRenamedTable() + { + final FlatXmlDatasetDocument datasetDocument = create("\n\n" + + "\n]>\n\n \n\n"); + final TestContext context = new TestContext(datasetDocument, "USERS"); + final RenameTableAction action = new RenameTableAction(context) + { + @Override + String openNameDialog(final Shell shell, final String currentName, + final IInputValidator validator, final DatasetTable table) + { + return "Customers"; + } + }; + + action.run(); + + assertThat(datasetDocument.getModel().getTables()) + .as("The renamed table must replace the old one, with no table left that only the DTD " + + "declares.") + .extracting(DatasetTable::getName).containsExactly("Customers"); + assertThat(datasetDocument.getModel().getProblems()) + .as("The DTD must declare the renamed table, so dbUnit can load the dataset.").isEmpty(); + } + + @Test + void testRenameTable_whenTheDialogReturnsANewName_renamesTheTable() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + final RenameTableAction action = new RenameTableAction(context) + { + @Override + String openNameDialog(final Shell shell, final String currentName, + final IInputValidator validator, final DatasetTable table) + { + return "Customers"; + } + }; + + action.run(); + + assertThat(datasetDocument.getModel().findTable("CUSTOMERS")).as("Rename Table must apply the entered name.") + .isPresent(); + } + + @Test + void testRenameTable_whenTheEditIsRejected_leavesTheTableAsItWas() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.editable = false; + final RenameTableAction action = new RenameTableAction(context) + { + @Override + String openNameDialog(final Shell shell, final String currentName, + final IInputValidator validator, final DatasetTable table) + { + return "Customers"; + } + }; + + action.run(); + + assertThat(datasetDocument.getModel().findTable("USERS")) + .as("A rejected edit must not rename the table.").isPresent(); + } + + @Test + void testRenameTable_whenCaseSensitiveAndAnotherTableHasTheTargetNameExactly_theDialogValidatorRejectsIt() + { + final FlatXmlDatasetDocument datasetDocument = + createCaseSensitive(""); + final TestContext context = new TestContext(datasetDocument, "users"); + final List capturedValidator = new ArrayList<>(); + final RenameTableAction action = new RenameTableAction(context) + { + @Override + String openNameDialog(final Shell shell, final String currentName, + final IInputValidator validator, final DatasetTable table) + { + capturedValidator.add(validator); + return null; + } + }; + + action.run(); + + assertThat(capturedValidator.get(0).isValid("USERS")) + .as("Renaming 'users' to 'USERS' must be rejected because a separate table already has " + + "that exact name, even though the validator excludes the table being renamed.") + .isNotNull(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/SelectAllActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/SelectAllActionTest.java new file mode 100644 index 00000000..5b2ecfc6 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/SelectAllActionTest.java @@ -0,0 +1,46 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link SelectAllAction} against the Commands specification: it selects every cell of the active + * grid. + */ +class SelectAllActionTest extends GridActionFixture +{ + @Test + void testSelectAll_run_selectsEveryCellOfTheActiveGrid() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + final SelectAllAction action = new SelectAllAction(context); + + action.run(); + + assertThat(context.selectAllCalled).as("Select All must select every cell of the active grid.") + .isTrue(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/SetEmptyStringActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/SetEmptyStringActionTest.java new file mode 100644 index 00000000..cdaa2dc7 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/SetEmptyStringActionTest.java @@ -0,0 +1,53 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.eclipse.swt.graphics.Point; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link SetEmptyStringAction} against the Commands specification: it sets the selected cells to the + * empty string, not to NULL. + */ +class SetEmptyStringActionTest extends GridActionFixture +{ + @Test + void testSetEmptyString_ofSelectedCells_setsThemToTheEmptyString() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0); + context.columnIndexes = List.of(1); + context.selectedCellPositions = List.of(new Point(1, 0)); + final SetEmptyStringAction action = new SetEmptyStringAction(context); + + action.run(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows().get(0).getValue(1)) + .as("Set to Empty String must store the empty string, not NULL.").isEqualTo(""); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/SetNullActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/SetNullActionTest.java new file mode 100644 index 00000000..90007363 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/SetNullActionTest.java @@ -0,0 +1,129 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.eclipse.swt.graphics.Point; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link SetNullAction} against the Commands specification: the rule for the first row, and the note + * on the status line about the columns to which the DTD gives a default value. + */ +class SetNullActionTest extends GridActionFixture +{ + @Test + void testSetNull_whenTheFirstRowWouldLoseAColumnThatAnotherRowHas_changesNothing() + { + final FlatXmlDatasetDocument datasetDocument = create( + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0); + context.columnIndexes = List.of(1); + context.selectedCellPositions = List.of(new Point(1, 0)); + + new SetNullAction(context).run(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows().get(0).getValue(1)) + .as("dbUnit would ignore Bob's name, so the first row's name must stay.") + .isEqualTo("Alice"); + } + + @Test + void testSetNull_whenTheColumnHasADefaultValue_explainsOnTheStatusLineThatDbUnitLoadsTheDefault() + { + final FlatXmlDatasetDocument datasetDocument = create(DEFAULTS_DATASET); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(1); + context.columnIndexes = List.of(1); + context.selectedCellPositions = List.of(new Point(1, 1)); + + new SetNullAction(context).run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows().get(1).getValue(1)).as("The attribute must be removed.").isNull(); + assertThat(table.getEffectiveValue(1, 1)).as("dbUnit loads the default for the removed attribute.") + .isEqualTo("ACTIVE"); + assertThat(context.statusMessage).as("The user must be told why the cell is not NULL.") + .isEqualTo("Column \"STATUS\" has the DTD default value \"ACTIVE\", so dbUnit loads it, " + + "not NULL, for a cell without a value."); + } + + @Test + void testSetNull_whenSeveralSelectedColumnsHaveDefaultValues_namesThemAllOnTheStatusLine() + { + final FlatXmlDatasetDocument datasetDocument = + create("\n" + + "\n]>\n" + + "" + + ""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 1); + context.columnIndexes = List.of(1, 2); + context.selectedCellPositions = + List.of(new Point(1, 0), new Point(2, 0), new Point(1, 1), new Point(2, 1)); + + new SetNullAction(context).run(); + + assertThat(context.statusMessage).as("Each column with a default must be named once.") + .isEqualTo("Columns STATUS, NOTE have DTD default values, so dbUnit loads them, not NULL, " + + "for cells without a value."); + } + + @Test + void testSetNull_whenNoSelectedColumnHasADefaultValue_showsNoStatusMessage() + { + final FlatXmlDatasetDocument datasetDocument = create(DEFAULTS_DATASET); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(1); + context.columnIndexes = List.of(0); + context.selectedCellPositions = List.of(new Point(0, 1)); + context.statusMessage = "earlier message"; + + new SetNullAction(context).run(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows().get(1).getValue(0)) + .as("The ID value must be removed.").isNull(); + assertThat(context.statusMessage).as("A column without a default needs no explanation.") + .isEqualTo("earlier message"); + } + + @Test + void testSetNull_whenTheEditIsRejected_showsNoStatusMessage() + { + final FlatXmlDatasetDocument datasetDocument = create(DEFAULTS_DATASET); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0); + context.columnIndexes = List.of(0, 1); + context.selectedCellPositions = List.of(new Point(0, 0), new Point(1, 0)); + + new SetNullAction(context).run(); + + assertThat(context.statusMessage) + .as("An edit that did not run must not claim that a default is loaded instead of NULL.") + .isNull(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/ShowInSourceActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/ShowInSourceActionTest.java new file mode 100644 index 00000000..2620eb64 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/ShowInSourceActionTest.java @@ -0,0 +1,63 @@ +/* + * + * 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.eclipse.dataset.ui.actions; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link ShowInSourceAction} against the Commands specification: it selects and reveals the anchor + * cell on the Source page, also on a read-only page. + */ +class ShowInSourceActionTest extends GridActionFixture +{ + @Test + void testShowInSource_run_selectsAndRevealsTheAnchorCellOnTheSourcePage() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.anchorColumnIndex = 0; + context.anchorRowIndex = 0; + final ShowInSourceAction action = new ShowInSourceAction(context); + + action.run(); + + assertThat(context.showInSourceCalled) + .as("Show in Source must select and reveal the anchor cell's range.").isTrue(); + } + + @Test + void testUpdate_forShowInSource_staysEnabledOnAReadOnlyPage() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.anchorColumnIndex = 0; + context.anchorRowIndex = 0; + context.editable = false; + final ShowInSourceAction action = new ShowInSourceAction(context); + + action.update(context.getSelection()); + + assertThat(action.isEnabled()).as("A read-only page must still show a cell in the source.").isTrue(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/dialogs/AddTableDialogTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/dialogs/AddTableDialogTest.java new file mode 100644 index 00000000..7ec96d33 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/dialogs/AddTableDialogTest.java @@ -0,0 +1,103 @@ +/* + * + * 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.eclipse.dataset.ui.dialogs; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Arrays; +import java.util.List; + +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; +import org.junit.jupiter.api.Test; + +/** + * Tests the column names {@link AddTableDialog} returns after the user confirms it. + */ +class AddTableDialogTest +{ + @Test + void testGetColumnNames_whenTheEnteredListHasBlankPartsAndATrailingComma_skipsThem() + { + final Shell parent = new Shell(Display.getDefault()); + try + { + final ConfirmableDialog dialog = new ConfirmableDialog(parent); + + dialog.enterAndConfirm("USERS", " ID , ,NAME,"); + + assertThat(dialog.getColumnNames()).as("Blank parts of the entered list must be skipped.") + .isEqualTo(List.of("ID", "NAME")); + } + finally + { + parent.dispose(); + } + } + + @Test + void testGetColumnNames_whenTheReturnedListIsModified_throws() + { + final Shell parent = new Shell(Display.getDefault()); + try + { + final ConfirmableDialog dialog = new ConfirmableDialog(parent); + dialog.enterAndConfirm("USERS", "ID,NAME"); + + assertThatThrownBy(() -> dialog.getColumnNames().add("EXTRA")) + .as("The returned column names must not let a caller change the dialog.") + .isInstanceOf(UnsupportedOperationException.class); + } + finally + { + parent.dispose(); + } + } + + /** + * An {@link AddTableDialog} that a test can fill in and confirm without showing it. + */ + private static final class ConfirmableDialog extends AddTableDialog + { + ConfirmableDialog(final Shell parent) + { + super(parent, name -> null); + } + + void enterAndConfirm(final String tableName, final String columnNames) + { + create(); + final Text[] texts = textFields((Composite) getDialogArea()); + texts[0].setText(tableName); + texts[1].setText(columnNames); + okPressed(); + } + + private static Text[] textFields(final Composite area) + { + return Arrays.stream(area.getChildren()).filter(Text.class::isInstance).map(Text.class::cast) + .toArray(Text[]::new); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/dialogs/ColumnNamesValidatorTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/dialogs/ColumnNamesValidatorTest.java new file mode 100644 index 00000000..5c1fb910 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/dialogs/ColumnNamesValidatorTest.java @@ -0,0 +1,90 @@ +/* + * + * 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.eclipse.dataset.ui.dialogs; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link ColumnNamesValidator} against the Add Table dialog's column-name rules. + */ +class ColumnNamesValidatorTest +{ + @Test + void testIsValid_forAColumnNameThatIsNotAValidXmlName_returnsAMessage() + { + final ColumnNamesValidator validator = new ColumnNamesValidator(); + + assertThat(validator.isValid(List.of("1st col"))) + .as("A name that is not a valid XML name must be rejected.").isNotNull(); + } + + @Test + void testIsValid_forDuplicateColumnNames_returnsAMessageRegardlessOfCase() + { + final ColumnNamesValidator validator = new ColumnNamesValidator(); + + assertThat(validator.isValid(List.of("ID", "id"))) + .as("Two column names that match, in any case, must be rejected.").isNotNull(); + } + + @Test + void testIsValid_forColumnNamesEqualIgnoringCaseYetDistinct_returnsNull() + { + final ColumnNamesValidator validator = new ColumnNamesValidator(); + + assertThat(validator.isValid(List.of("ID", "\u0130D"))) + .as("Column names with different keys must be accepted, although they are equal ignoring " + + "case.") + .isNull(); + } + + @Test + void testIsValid_forColumnNamesThatShareAKeyYetAreNotEqualIgnoringCase_returnsAMessage() + { + final ColumnNamesValidator validator = new ColumnNamesValidator(); + + assertThat(validator.isValid(List.of("straße", "STRASSE"))) + .as("Two column names with the same key must be rejected, although they are not equal " + + "ignoring case.") + .isNotNull(); + } + + @Test + void testIsValid_forUniqueValidColumnNames_returnsNull() + { + final ColumnNamesValidator validator = new ColumnNamesValidator(); + + assertThat(validator.isValid(List.of("ID", "NAME"))).as("Unique, valid XML names must be accepted.") + .isNull(); + } + + @Test + void testIsValid_forNoColumnNames_returnsNull() + { + final ColumnNamesValidator validator = new ColumnNamesValidator(); + + assertThat(validator.isValid(List.of())).as("An empty column list must be accepted.").isNull(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/dialogs/DatasetNameValidatorTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/dialogs/DatasetNameValidatorTest.java new file mode 100644 index 00000000..cefa21e4 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/dialogs/DatasetNameValidatorTest.java @@ -0,0 +1,95 @@ +/* + * + * 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.eclipse.dataset.ui.dialogs; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link DatasetNameValidator} against the Column Commands specification's naming rules. + */ +class DatasetNameValidatorTest +{ + @Test + void testIsValid_forANameThatIsNotAValidXmlName_returnsAMessage() + { + final DatasetNameValidator validator = new DatasetNameValidator(List.of("ID")); + + assertThat(validator.isValid("1BAD")).as("A name that is not a valid XML name must be rejected.") + .isNotNull(); + } + + @Test + void testIsValid_forANameAlreadyUsed_returnsAMessageRegardlessOfCase() + { + final DatasetNameValidator validator = new DatasetNameValidator(List.of("ID", "NAME")); + + assertThat(validator.isValid("name")).as("A name already used, in any case, must be rejected.") + .isNotNull(); + } + + @Test + void testIsValid_forANameEqualIgnoringCaseToAUsedNameYetDistinct_returnsNull() + { + final DatasetNameValidator validator = new DatasetNameValidator(List.of("\u0130D")); + + assertThat(validator.isValid("ID")) + .as("A name with another key than every used name must be accepted, although it equals " + + "one of them ignoring case.") + .isNull(); + } + + @Test + void testIsValid_forANameThatSharesAKeyWithAUsedNameYetIsNotEqualIgnoringCase_returnsAMessage() + { + final DatasetNameValidator validator = new DatasetNameValidator(List.of("straße")); + + assertThat(validator.isValid("STRASSE")) + .as("A name with the key of a used name must be rejected, although the two names are not " + + "equal ignoring case.") + .isNotNull(); + } + + @Test + void testIsValid_forAUniqueValidXmlName_returnsNull() + { + final DatasetNameValidator validator = new DatasetNameValidator(List.of("ID", "NAME")); + + assertThat(validator.isValid("EMAIL")).as("A unique, valid XML name must be accepted.").isNull(); + } + + @Test + void testIsValid_whenTheGivenNamesChangeAfterwards_stillRejectsTheNamesItWasGiven() + { + final List existingNames = new ArrayList<>(List.of("ID")); + final DatasetNameValidator validator = new DatasetNameValidator(existingNames); + + existingNames.clear(); + + assertThat(validator.isValid("ID")) + .as("A later change to the caller's list must not change which names are rejected.") + .isNotNull(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/dialogs/TableNameValidatorTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/dialogs/TableNameValidatorTest.java new file mode 100644 index 00000000..74c645fc --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/dialogs/TableNameValidatorTest.java @@ -0,0 +1,132 @@ +/* + * + * 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.eclipse.dataset.ui.dialogs; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.function.Function; + +import org.junit.jupiter.api.Test; + +/** + * Tests {@link TableNameValidator} against the document's table-naming rules. + */ +class TableNameValidatorTest +{ + private static final Function CASE_INSENSITIVE = name -> name.toUpperCase(Locale.ENGLISH); + + private static final Function CASE_SENSITIVE = Function.identity(); + + @Test + void testIsValid_forANameThatIsNotAValidXmlName_returnsAMessage() + { + final TableNameValidator validator = new TableNameValidator(CASE_INSENSITIVE, List.of("USERS")); + + assertThat(validator.isValid("1BAD")).as("A name that is not a valid XML name must be rejected.") + .isNotNull(); + } + + @Test + void testIsValid_forAUniqueValidXmlName_returnsNull() + { + final TableNameValidator validator = new TableNameValidator(CASE_INSENSITIVE, List.of("USERS")); + + assertThat(validator.isValid("ORDERS")).as("A unique, valid XML name must be accepted.").isNull(); + } + + @Test + void testIsValid_whenCaseInsensitive_rejectsANameUsedByAnotherTableRegardlessOfCase() + { + final TableNameValidator validator = new TableNameValidator(CASE_INSENSITIVE, List.of("USERS")); + + assertThat(validator.isValid("users")) + .as("A case-insensitive document must reject a name already used, in any case.") + .isNotNull(); + } + + @Test + void testIsValid_whenCaseSensitive_acceptsANameUsedByAnotherTableOnlyInDifferentCase() + { + final TableNameValidator validator = new TableNameValidator(CASE_SENSITIVE, List.of("USERS")); + + assertThat(validator.isValid("users")) + .as("A case-sensitive document must accept a name that differs from another table only " + + "in case.") + .isNull(); + } + + @Test + void testIsValid_whenCaseSensitive_rejectsANameUsedByAnotherTableInTheSameCase() + { + final TableNameValidator validator = new TableNameValidator(CASE_SENSITIVE, List.of("USERS")); + + assertThat(validator.isValid("USERS")) + .as("A case-sensitive document must still reject a name already used in the same case.") + .isNotNull(); + } + + @Test + void testIsValid_whenCaseInsensitive_rejectsAnyCasingOfTheReservedRootName() + { + final TableNameValidator validator = new TableNameValidator(CASE_INSENSITIVE, List.of()); + + assertThat(validator.isValid("Dataset")) + .as("A case-insensitive document must reject every casing of the reserved root name.") + .isNotNull(); + } + + @Test + void testIsValid_whenCaseSensitive_rejectsTheReservedRootNameExactly() + { + final TableNameValidator validator = new TableNameValidator(CASE_SENSITIVE, List.of()); + + assertThat(validator.isValid("dataset")) + .as("A case-sensitive document must reject the reserved root name spelled exactly.") + .isNotNull(); + } + + @Test + void testIsValid_whenCaseSensitive_acceptsADifferentCasingOfTheReservedRootName() + { + final TableNameValidator validator = new TableNameValidator(CASE_SENSITIVE, List.of()); + + assertThat(validator.isValid("Dataset")) + .as("A case-sensitive document must accept a casing of the reserved root name that does " + + "not match it exactly.") + .isNull(); + } + + @Test + void testIsValid_whenTheGivenNamesChangeAfterwards_stillRejectsTheNamesItWasGiven() + { + final List existingNames = new ArrayList<>(List.of("USERS")); + final TableNameValidator validator = new TableNameValidator(CASE_INSENSITIVE, existingNames); + + existingNames.clear(); + + assertThat(validator.isValid("USERS")) + .as("A later change to the caller's list must not change which names are rejected.") + .isNotNull(); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/ActiveGridTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/ActiveGridTest.java new file mode 100644 index 00000000..cf191645 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/ActiveGridTest.java @@ -0,0 +1,291 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.dbunit.eclipse.dataset.core.dtd.DtdSource; +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlOptions; +import org.dbunit.eclipse.dataset.core.model.CellAddress; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.jface.resource.JFaceResources; +import org.eclipse.jface.resource.LocalResourceManager; +import org.eclipse.jface.text.Document; +import org.eclipse.nebula.widgets.nattable.NatTable; +import org.eclipse.nebula.widgets.nattable.edit.command.EditSelectionCommand; +import org.eclipse.nebula.widgets.nattable.selection.command.SelectCellCommand; +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.CTabFolder; +import org.eclipse.swt.custom.CTabItem; +import org.eclipse.swt.graphics.Point; +import org.eclipse.swt.layout.FillLayout; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link ActiveGrid}: the selection of the grid of the selected tab, the cells and regions that it + * selects, the cell editor that is open in it, and the address of its anchor cell. + */ +class ActiveGridTest +{ + private static final String USERS = + ""; + + private Shell shell; + + private Document document; + + private FlatXmlDatasetDocument datasetDocument; + + private CTabFolder tabFolder; + + private TableTabs tabs; + + private ActiveGrid activeGrid; + + @BeforeEach + void createActiveGrid() + { + shell = new Shell(Display.getDefault()); + shell.setLayout(new FillLayout()); + shell.setSize(400, 300); + document = new Document(""); + datasetDocument = new FlatXmlDatasetDocument(document, DtdSource.NONE, FlatXmlOptions.DBUNIT_DEFAULTS, + () -> StandardCharsets.UTF_8); + datasetDocument.refresh(); + tabFolder = new CTabFolder(shell, SWT.TOP | SWT.BORDER | SWT.FLAT); + final LocalResourceManager resources = new LocalResourceManager(JFaceResources.getResources(), shell); + tabs = new TableTabs(tabFolder, new StubGridContext(datasetDocument), resources, () -> + { + // The tests ask for the selection when they need it. + }); + activeGrid = new ActiveGrid(tabFolder, tabs::activeGrid); + shell.open(); + UiTestWorkspace.processEvents(); + } + + @AfterEach + void disposeShell() + { + shell.dispose(); + } + + @Test + void testGetSelection_whenNoTabExists_isNone() + { + assertThat(activeGrid.getSelection()).as("Without a grid nothing is selected.") + .isEqualTo(GridSelection.NONE); + } + + @Test + void testGetSelection_whenATabIsSelected_isTheSelectionOfItsGrid() + { + show(USERS); + + assertThat(activeGrid.getSelection()).as("The first cell is selected when the grid opens.") + .isEqualTo(new GridSelection("USERS", 2, 2, 0, 0, List.of(0), List.of(0), false)); + } + + @Test + void testSelectRegion_whenATabIsSelected_selectsTheRegionOfItsGrid() + { + show(USERS); + + activeGrid.selectRegion(0, 0, 2, 2); + + assertThat(activeGrid.getSelection()).as("Both rows and both columns must be selected.") + .isEqualTo(new GridSelection("USERS", 2, 2, 0, 0, List.of(0, 1), List.of(0, 1), true)); + } + + @Test + void testSelectRegion_whenNoTabExists_doesNothing() + { + activeGrid.selectRegion(0, 0, 2, 2); + + assertThat(activeGrid.getSelection()).as("Without a grid nothing is selected.") + .isEqualTo(GridSelection.NONE); + } + + @Test + void testGetSelectedCellPositions_afterSelectingARegion_areTheColumnAndRowOfEachCell() + { + show(USERS); + + activeGrid.selectRegion(0, 1, 2, 1); + + assertThat(activeGrid.getSelectedCellPositions()).as("The cells of the region must be listed.") + .containsExactlyInAnyOrder(new Point(0, 1), new Point(1, 1)); + } + + @Test + void testGetSelectedCellPositions_whenNoTabExists_isEmpty() + { + assertThat(activeGrid.getSelectedCellPositions()).as("Without a grid no cell is selected.").isEmpty(); + } + + @Test + void testSelectAll_whenATabIsSelected_selectsEveryCellOfItsGrid() + { + show(USERS); + + activeGrid.selectAll(); + + assertThat(activeGrid.getSelectedCellPositions()).as("Every cell must be selected.") + .containsExactlyInAnyOrder(new Point(0, 0), new Point(1, 0), new Point(0, 1), + new Point(1, 1)); + } + + @Test + void testSelectAll_whenNoTabExists_doesNothing() + { + activeGrid.selectAll(); + + assertThat(activeGrid.getSelectedCellPositions()).as("Without a grid no cell is selected.").isEmpty(); + } + + @Test + void testEditCellInDialog_whenNoTabExists_doesNothing() + { + activeGrid.editCellInDialog(); + + assertThat(activeGrid.hasActiveCellEditor()).as("Without a grid no editor opens.").isFalse(); + } + + @Test + void testHasActiveCellEditor_whenNoTabExists_isFalse() + { + assertThat(activeGrid.hasActiveCellEditor()).as("Without a grid no cell is edited.").isFalse(); + } + + @Test + void testHasActiveCellEditor_whenNoCellIsBeingEdited_isFalse() + { + show(USERS); + + assertThat(activeGrid.hasActiveCellEditor()).as("No editor is open.").isFalse(); + } + + @Test + void testHasActiveCellEditor_whileACellIsBeingEdited_isTrue() + { + show(USERS); + editCell(1, 2); + + assertThat(activeGrid.hasActiveCellEditor()).as("An editor is open.").isTrue(); + } + + @Test + void testHasActiveCellEditor_whenTheSelectedTabHoldsNoGrid_isFalse() + { + final CTabItem item = new CTabItem(tabFolder, SWT.NONE); + item.setControl(new Composite(tabFolder, SWT.NONE)); + tabFolder.setSelection(item); + + assertThat(activeGrid.hasActiveCellEditor()).as("Only a grid has cell editors.").isFalse(); + } + + @Test + void testGetActiveCellEditorText_whenNoTabExists_isNull() + { + assertThat(activeGrid.getActiveCellEditorText()).as("Without a grid no cell is edited.").isNull(); + } + + @Test + void testGetActiveCellEditorText_whenNoCellIsBeingEdited_isNull() + { + show(USERS); + + assertThat(activeGrid.getActiveCellEditorText()).as("No editor is open.").isNull(); + } + + @Test + void testGetActiveCellEditorText_whileACellIsBeingEdited_isTheTextOfTheEditor() + { + show(USERS); + final NatTable natTable = editCell(1, 2); + natTable.getActiveCellEditor().setEditorValue("20"); + + final Text text = activeGrid.getActiveCellEditorText(); + + assertThat(text.getText()).as("The editor's text must be the value being edited.").isEqualTo("20"); + } + + @Test + void testGetActiveCellEditorText_whenTheSelectedTabHoldsNoGrid_isNull() + { + final CTabItem item = new CTabItem(tabFolder, SWT.NONE); + item.setControl(new Composite(tabFolder, SWT.NONE)); + tabFolder.setSelection(item); + + assertThat(activeGrid.getActiveCellEditorText()).as("Only a grid has cell editors.").isNull(); + } + + @Test + void testCurrentCellAddress_whenNoTabExists_isNull() + { + assertThat(activeGrid.currentCellAddress()).as("Without a grid there is no anchor cell.").isNull(); + } + + @Test + void testCurrentCellAddress_whenTheGridHasAnAnchorCell_isItsAddress() + { + show(USERS); + + activeGrid.selectRegion(1, 0, 1, 1); + + assertThat(activeGrid.currentCellAddress()).as("The address must name the table, row, and column.") + .isEqualTo(new CellAddress("USERS", 0, 1)); + } + + @Test + void testCurrentCellAddress_whenTheTableHasNoRows_isNull() + { + show(""); + + assertThat(activeGrid.currentCellAddress()).as("A table without rows has no anchor cell.").isNull(); + } + + private void show(final String content) + { + document.set(content); + datasetDocument.refresh(); + tabs.reconcile(datasetDocument.getModel()); + shell.layout(true, true); + UiTestWorkspace.processEvents(); + } + + private NatTable editCell(final int columnPosition, final int rowPosition) + { + final NatTable natTable = (NatTable) tabFolder.getSelection().getControl(); + natTable.doCommand(new SelectCellCommand(natTable, columnPosition, rowPosition, false, false)); + natTable.doCommand(new EditSelectionCommand(natTable, natTable.getConfigRegistry())); + return natTable; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/DatasetEditorContributorTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/DatasetEditorContributorTest.java new file mode 100644 index 00000000..b8808abe --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/DatasetEditorContributorTest.java @@ -0,0 +1,380 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.core.resources.IFile; +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.action.IContributionItem; +import org.eclipse.jface.action.IStatusLineManager; +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.CTabFolder; +import org.eclipse.swt.custom.CTabItem; +import org.eclipse.swt.widgets.Event; +import org.eclipse.ui.IActionBars; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.actions.ActionFactory; +import org.eclipse.ui.texteditor.ITextEditor; +import org.eclipse.ui.texteditor.ITextEditorActionConstants; +import org.junit.jupiter.api.Test; + +/** + * Tests that {@link DatasetEditorContributor} installs the global Edit, Revert, and Print actions of the + * active dataset editor's active page, and shows the status fields of the Source page only while it is + * active. + */ +class DatasetEditorContributorTest +{ + private static final String DATASET = ""; + + private static final String[] GLOBAL_ACTION_IDS = { ActionFactory.UNDO.getId(), ActionFactory.REDO.getId(), + ActionFactory.CUT.getId(), ActionFactory.COPY.getId(), ActionFactory.PASTE.getId(), + ActionFactory.DELETE.getId(), ActionFactory.SELECT_ALL.getId(), ActionFactory.FIND.getId() }; + + private static final String[] TEXT_EDITOR_ACTION_IDS = { ITextEditorActionConstants.UNDO, + ITextEditorActionConstants.REDO, ITextEditorActionConstants.CUT, ITextEditorActionConstants.COPY, + ITextEditorActionConstants.PASTE, ITextEditorActionConstants.DELETE, + ITextEditorActionConstants.SELECT_ALL, ITextEditorActionConstants.FIND }; + + @Test + void testSetActiveEditor_whenAnEditorOpensOnTheTablesPage_installsTheTablesPageActions() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", DATASET); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + + assertThat(installedActions(editor)) + .as("An editor that opens on the Tables page must install that page's Edit actions.") + .isEqualTo(tablesPageActions(editor)); + } + } + + @Test + void testSetActivePage_toTheSourcePage_installsTheTextEditorActions() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", DATASET); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + + editor.showOnSourcePage(0, 0); + UiTestWorkspace.processEvents(); + + assertThat(installedActions(editor)).as("The Source page must install the text editor's Edit actions.") + .isEqualTo(textEditorActions(editor.getSourceEditor())); + } + } + + @Test + void testSetActiveEditor_withTwoDatasetEditorsOpen_installsTheActivatedEditorsActions() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final FlatXmlDatasetEditor first = + (FlatXmlDatasetEditor) workspace.open(workspace.createFile("first.xml", DATASET)); + final FlatXmlDatasetEditor second = + (FlatXmlDatasetEditor) workspace.open(workspace.createFile("second.xml", DATASET)); + + PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage().activate(first); + UiTestWorkspace.processEvents(); + + assertThat(installedActions(first)) + .as("Activating an editor must install its own Tables page's Edit actions, not those of the " + + "editor that was active before.") + .isEqualTo(tablesPageActions(first)) + .isNotEqualTo(tablesPageActions(second)); + } + } + + @Test + void testSetActiveEditor_whenAnEditorOpensOnTheTablesPage_installsTheTextEditorsRevertAction() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", DATASET); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + + assertThat(installedAction(editor, ActionFactory.REVERT)) + .as("File > Revert must work on the Tables page too, which edits the same document.") + .isNotNull() + .isSameAs(editor.getSourceEditor().getAction(ITextEditorActionConstants.REVERT)); + } + } + + @Test + void testSetActiveEditor_whenAnEditorOpensOnTheTablesPage_installsNoPrintAction() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", DATASET); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + + assertThat(installedAction(editor, ActionFactory.PRINT)) + .as("The grid has nothing to print, so File > Print must stay disabled on the Tables page.") + .isNull(); + } + } + + @Test + void testSetActivePage_toTheSourcePage_installsTheTextEditorsPrintAndRevertActions() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", DATASET); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final ITextEditor sourceEditor = editor.getSourceEditor(); + + editor.showOnSourcePage(0, 0); + UiTestWorkspace.processEvents(); + + assertThat(installedAction(editor, ActionFactory.PRINT)) + .as("The Source page must install the text editor's Print action.").isNotNull() + .isSameAs(sourceEditor.getAction(ITextEditorActionConstants.PRINT)); + assertThat(installedAction(editor, ActionFactory.REVERT)) + .as("The Source page must install the text editor's Revert action.").isNotNull() + .isSameAs(sourceEditor.getAction(ITextEditorActionConstants.REVERT)); + } + } + + @Test + void testSetActivePage_backToTheTablesPage_removesThePrintActionAndKeepsTheRevertAction() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", DATASET); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + editor.showOnSourcePage(0, 0); + UiTestWorkspace.processEvents(); + + showTablesPage(editor); + + assertThat(installedAction(editor, ActionFactory.PRINT)) + .as("Leaving the Source page must remove its Print action.").isNull(); + assertThat(installedAction(editor, ActionFactory.REVERT)) + .as("The text editor's Revert action must stay installed on the Tables page.") + .isNotNull() + .isSameAs(editor.getSourceEditor().getAction(ITextEditorActionConstants.REVERT)); + } + } + + @Test + void testSetActiveEditor_withTwoDatasetEditorsOpen_installsTheActivatedEditorsRevertAction() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final FlatXmlDatasetEditor first = + (FlatXmlDatasetEditor) workspace.open(workspace.createFile("first.xml", DATASET)); + final FlatXmlDatasetEditor second = + (FlatXmlDatasetEditor) workspace.open(workspace.createFile("second.xml", DATASET)); + + PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage().activate(first); + UiTestWorkspace.processEvents(); + + assertThat(installedAction(first, ActionFactory.REVERT)) + .as("Activating an editor must install its own Revert action, not that of the editor " + + "that was active before.") + .isSameAs(first.getSourceEditor().getAction(ITextEditorActionConstants.REVERT)) + .isNotSameAs(second.getSourceEditor().getAction(ITextEditorActionConstants.REVERT)); + } + } + + @Test + void testContributeToStatusLine_whenAnEditorOpens_addsTheTextEditorsFieldsToItsStatusLine() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", DATASET); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final IActionBars actionBars = editor.getEditorSite().getActionBars(); + final IStatusLineManager statusLine = actionBars.getStatusLineManager(); + + final List fields = new ArrayList<>(); + for (final String category : StatusFieldProbe.CATEGORIES) + { + fields.add(statusLine.find(category)); + } + + assertThat(fields).as("The status line of the dataset editor must have the Source page's fields.") + .doesNotContainNull(); + } + } + + @Test + void testSetActivePage_toTheSourcePage_showsTheStatusFieldsOfTheSourcePage() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", DATASET); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final int usersOffset = DATASET.indexOf(""); + + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + + try (StatusFieldProbe probe = new StatusFieldProbe(statusFieldsOf(editor))) + { + assertThat(editor.isSourcePageActive()) + .as("A file with an XML error must open on the Source page.").isTrue(); + assertThat(probe.visibleCategories()) + .as("An editor that opens on the Source page must show its status fields.") + .containsExactly(StatusFieldProbe.ELEMENT_STATE, StatusFieldProbe.INPUT_MODE, + StatusFieldProbe.INPUT_POSITION); + } + } + } + + @Test + void testSetActivePage_backToTheTablesPage_hidesTheStatusFields() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", DATASET); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + editor.showOnSourcePage(0, 0); + UiTestWorkspace.processEvents(); + + showTablesPage(editor); + + try (StatusFieldProbe probe = new StatusFieldProbe(statusFieldsOf(editor))) + { + assertThat(probe.visibleCategories()) + .as("The Tables page has no use for the status fields of the text editor.").isEmpty(); + } + } + } + + @Test + void testSetActiveEditor_withTwoDatasetEditorsOpen_showsTheStatusFieldsOfTheActivatedSourcePageOnly() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final FlatXmlDatasetEditor first = + (FlatXmlDatasetEditor) workspace.open(workspace.createFile("first.xml", DATASET)); + first.showOnSourcePage(0, 0); + final FlatXmlDatasetEditor second = + (FlatXmlDatasetEditor) workspace.open(workspace.createFile("second.xml", DATASET)); + final int usersOffset = DATASET.indexOf(" whileTheSecondEditorShowsItsTablesPage = probe.visibleCategories(); + + PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage().activate(first); + UiTestWorkspace.processEvents(); + second.getSourceEditor().selectAndReveal(1, 0); + first.getSourceEditor().selectAndReveal(usersOffset, 0); + + assertThat(whileTheSecondEditorShowsItsTablesPage) + .as("An editor on its Tables page must show no status fields.").isEmpty(); + assertThat(probe.visibleCategories()) + .as("Activating an editor on its Source page must show the fields.") + .containsExactly(StatusFieldProbe.ELEMENT_STATE, StatusFieldProbe.INPUT_MODE, + StatusFieldProbe.INPUT_POSITION); + assertThat(probe.position()) + .as("The fields must follow the activated editor, not the one before.") + .isEqualTo("1:" + (usersOffset + 1)); + } + } + } + + private static SourceStatusFields statusFieldsOf(final FlatXmlDatasetEditor editor) + { + final DatasetEditorContributor contributor = + (DatasetEditorContributor) editor.getEditorSite().getActionBarContributor(); + return contributor.getStatusFields(); + } + + private static IAction installedAction(final FlatXmlDatasetEditor editor, final ActionFactory factory) + { + return editor.getEditorSite().getActionBars().getGlobalActionHandler(factory.getId()); + } + + private static void showTablesPage(final FlatXmlDatasetEditor editor) + { + final CTabFolder pages = (CTabFolder) editor.getTablesPage().getControl().getParent(); + final CTabItem tablesTab = pages.getItem(0); + pages.setSelection(tablesTab); + final Event click = new Event(); + click.item = tablesTab; + pages.notifyListeners(SWT.Selection, click); + UiTestWorkspace.processEvents(); + } + + private static List installedActions(final FlatXmlDatasetEditor editor) + { + final IActionBars actionBars = editor.getEditorSite().getActionBars(); + final List actions = new ArrayList<>(); + for (final String id : GLOBAL_ACTION_IDS) + { + actions.add(actionBars.getGlobalActionHandler(id)); + } + return actions; + } + + private static List tablesPageActions(final FlatXmlDatasetEditor editor) + { + final List actions = new ArrayList<>(); + for (final String id : GLOBAL_ACTION_IDS) + { + actions.add(editor.getTablesPage().getGlobalActionHandler(id)); + } + return actions; + } + + private static List textEditorActions(final ITextEditor textEditor) + { + final List actions = new ArrayList<>(); + for (final String id : TEXT_EDITOR_ACTION_IDS) + { + actions.add(textEditor.getAction(id)); + } + return actions; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/EditorInputDtdSourceTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/EditorInputDtdSourceTest.java new file mode 100644 index 00000000..a5e18ec8 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/EditorInputDtdSourceTest.java @@ -0,0 +1,578 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.Optional; +import java.util.function.Function; + +import org.eclipse.core.filesystem.EFS; +import org.eclipse.core.filesystem.IFileStore; +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.Status; +import org.eclipse.jface.resource.ImageDescriptor; +import org.eclipse.ui.IEditorInput; +import org.eclipse.ui.IPersistableElement; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.EnabledOnOs; +import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; + +/** + * Tests {@link EditorInputDtdSource} against the DTD Resolution rules. + */ +class EditorInputDtdSourceTest +{ + @Test + void testLoad_whenSystemIdIsARelativeWorkspacePath_readsTheWorkspaceFile() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + workspace.createFile("my.dtd", ""); + final IFile datasetFile = workspace.createFile("dataset.xml", ""); + final EditorInputDtdSource source = new EditorInputDtdSource(fileInput(datasetFile)); + + final Optional loaded = source.load(null, "my.dtd"); + + assertThat(loaded) + .as("A relative system ID must resolve against the dataset file's folder.") + .contains(""); + } + } + + @Test + void testLoad_whenSystemIdIsARelativeReferenceFromANonWorkspaceInput_readsThroughItsFileStore( + @TempDir final Path tempDir) throws Exception + { + Files.writeString(tempDir.resolve("my.dtd"), ""); + final IFileStore datasetStore = EFS.getStore(tempDir.resolve("dataset.xml").toUri()); + final EditorInputDtdSource source = new EditorInputDtdSource(fileStoreInput(datasetStore)); + + final Optional loaded = source.load(null, "my.dtd"); + + assertThat(loaded) + .as("A relative system ID must resolve against a non-workspace input's own location.") + .contains(""); + } + + @Test + void testLoad_whenTheRelativeSystemIdHasASpace_readsTheFile(@TempDir final Path tempDir) throws Exception + { + Files.createDirectories(tempDir.resolve("test data")); + Files.writeString(tempDir.resolve("test data").resolve("my dtd.dtd"), ""); + final EditorInputDtdSource source = new EditorInputDtdSource( + fileStoreInput(EFS.getStore(tempDir.resolve("dataset.xml").toUri()))); + + final Optional loaded = source.load(null, "test data/my dtd.dtd"); + + assertThat(loaded).as("An XML parser escapes a space in a system ID and reads the file, so must the " + + "editor.").contains(""); + } + + @Test + void testLoad_whenTheRelativeSystemIdHasAnEscapedSpace_readsTheFileWithTheSpace( + @TempDir final Path tempDir) throws Exception + { + Files.createDirectories(tempDir.resolve("test data")); + Files.writeString(tempDir.resolve("test data").resolve("my.dtd"), ""); + final EditorInputDtdSource source = new EditorInputDtdSource( + fileStoreInput(EFS.getStore(tempDir.resolve("dataset.xml").toUri()))); + + final Optional loaded = source.load(null, "test%20data/my.dtd"); + + assertThat(loaded).as("An escape sequence in a system ID is already escaped, and stays so.") + .contains(""); + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void testLoad_whenTheRelativeSystemIdUsesBackslashesOnWindows_readsTheFile(@TempDir final Path tempDir) + throws Exception + { + Files.createDirectories(tempDir.resolve("dtd")); + Files.writeString(tempDir.resolve("dtd").resolve("my.dtd"), ""); + final EditorInputDtdSource source = new EditorInputDtdSource( + fileStoreInput(EFS.getStore(tempDir.resolve("dataset.xml").toUri()))); + + final Optional loaded = source.load(null, "dtd\\my.dtd"); + + assertThat(loaded).as("On Windows a parser takes a backslash in a system ID for a slash.") + .contains(""); + } + + @Test + void testLoad_whenTheFileUriHasASpace_readsTheFile(@TempDir final Path tempDir) throws Exception + { + Files.createDirectories(tempDir.resolve("test data")); + Files.writeString(tempDir.resolve("test data").resolve("my.dtd"), ""); + final EditorInputDtdSource source = new EditorInputDtdSource( + fileStoreInput(EFS.getStore(tempDir.resolve("dataset.xml").toUri()))); + + final Optional loaded = source.load(null, tempDir.toUri() + "test data/my.dtd"); + + assertThat(loaded).as("A file: URI with a space is escaped like a relative path.") + .contains(""); + } + + @Test + void testLoad_whenSystemIdIsAFileUri_readsThatFile(@TempDir final Path tempDir) throws Exception + { + final Path dtdPath = tempDir.resolve("external.dtd"); + Files.writeString(dtdPath, ""); + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile datasetFile = workspace.createFile("dataset.xml", ""); + final EditorInputDtdSource source = new EditorInputDtdSource(fileInput(datasetFile)); + + final Optional loaded = source.load(null, dtdPath.toUri().toString()); + + assertThat(loaded).as("A file: URI system ID must be read directly.") + .contains(""); + } + } + + @Test + void testLoad_whenSystemIdIsAFileUriInAnotherCharset_readsUsingItsDeclaredEncoding( + @TempDir final Path tempDir) throws Exception + { + final Path dtdPath = tempDir.resolve("external.dtd"); + final String dtdText = "\n" + + "\n\n"; + Files.write(dtdPath, dtdText.getBytes(StandardCharsets.ISO_8859_1)); + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile datasetFile = workspace.createFile("dataset.xml", ""); + final EditorInputDtdSource source = new EditorInputDtdSource(fileInput(datasetFile)); + + final Optional loaded = source.load(null, dtdPath.toUri().toString()); + + assertThat(loaded) + .as("A file: URI system ID must be decoded using its own declared encoding.") + .contains(dtdText); + } + } + + @Test + void testLoad_whenSystemIdIsHttp_returnsEmpty() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile datasetFile = workspace.createFile("dataset.xml", ""); + final EditorInputDtdSource source = new EditorInputDtdSource(fileInput(datasetFile)); + + final Optional loaded = source.load(null, "http://example.com/my.dtd"); + + assertThat(loaded).as("The editor must never read an http: system ID.").isEmpty(); + } + } + + @Test + void testLoad_whenTheFileDoesNotExist_returnsEmpty() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile datasetFile = workspace.createFile("dataset.xml", ""); + final EditorInputDtdSource source = new EditorInputDtdSource(fileInput(datasetFile)); + + final Optional loaded = source.load(null, "does-not-exist.dtd"); + + assertThat(loaded).as("A missing file must yield no DTD text.").isEmpty(); + } + } + + @Test + void testLoad_whenSystemIdIsAnOpaqueFileUri_returnsEmpty() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile datasetFile = workspace.createFile("dataset.xml", ""); + final EditorInputDtdSource source = new EditorInputDtdSource(fileInput(datasetFile)); + + final Optional loaded = source.load(null, "file:dataset.dtd"); + + assertThat(loaded).as("An opaque file: URI must not throw; it must yield no DTD text.") + .isEmpty(); + } + } + + @Test + void testLoad_whenSystemIdIsAFileUriWithAQuery_returnsEmpty() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile datasetFile = workspace.createFile("dataset.xml", ""); + final EditorInputDtdSource source = new EditorInputDtdSource(fileInput(datasetFile)); + + final Optional loaded = source.load(null, "file:///dtd/dataset.dtd?x=1"); + + assertThat(loaded) + .as("A file: URI with a query must not throw, on every platform, even though it " + + "resolves to no DTD text.") + .isEmpty(); + } + } + + @Test + void testLoad_whenSystemIdClimbsAboveTheWorkspaceRoot_returnsEmpty() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile datasetFile = workspace.createFile("dataset.xml", ""); + final EditorInputDtdSource source = new EditorInputDtdSource(fileInput(datasetFile)); + + final Optional loaded = source.load(null, "../dataset.dtd"); + + assertThat(loaded) + .as("A system ID that climbs above the workspace root must not throw.").isEmpty(); + } + } + + @Test + void testLoad_whenSystemIdClimbsAboveTheProjectToARealFile_readsItsRealLocationOnDisk() + throws Exception + { + final Path siblingDtd = ResourcesPlugin.getWorkspace().getRoot().getLocation().toFile().toPath() + .resolve("editor-input-dtd-source-test-sibling.dtd"); + Files.writeString(siblingDtd, ""); + try + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile datasetFile = workspace.createFile("dataset.xml", ""); + final EditorInputDtdSource source = new EditorInputDtdSource(fileInput(datasetFile)); + + final Optional loaded = + source.load(null, "../" + siblingDtd.getFileName()); + + assertThat(loaded) + .as("A relative system ID that leaves the project must resolve against the " + + "dataset file's real location on disk, not a workspace-relative path.") + .contains(""); + } + } + finally + { + Files.deleteIfExists(siblingDtd); + } + } + + @Test + void testLoad_whenSystemIdIsAnAbsolutePlatformPath_readsTheFile(@TempDir final Path tempDir) + throws Exception + { + final Path dtdPath = tempDir.resolve("my.dtd"); + Files.writeString(dtdPath, ""); + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile datasetFile = workspace.createFile("dataset.xml", ""); + final EditorInputDtdSource source = new EditorInputDtdSource(fileInput(datasetFile)); + + final Optional loaded = source.load(null, dtdPath.toString()); + + assertThat(loaded).as("An absolute path in the platform's own syntax must be read directly.") + .contains(""); + } + } + + @Test + void testLoad_whenSystemIdIsAWindowsPathWithAForbiddenCharacter_returnsEmpty() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile datasetFile = workspace.createFile("dataset.xml", ""); + final EditorInputDtdSource source = new EditorInputDtdSource(fileInput(datasetFile)); + + final Optional loaded = source.load(null, "C:\\invalid?name.dtd"); + + assertThat(loaded).as("A platform path with a forbidden character must not throw.").isEmpty(); + } + } + + @Test + void testLoad_whenTheWorkspaceFilesCharsetOverrideIsUnsupported_ignoresItAndReadsTheFile() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile dtdFile = workspace.createFile("my.dtd", ""); + dtdFile.setCharset("totally-bogus-charset", null); + final IFile datasetFile = workspace.createFile("dataset.xml", ""); + final EditorInputDtdSource source = new EditorInputDtdSource(fileInput(datasetFile)); + + final Optional loaded = source.load(null, "my.dtd"); + + assertThat(loaded) + .as("Resolving by the file's real location, like dbUnit's own parser, must not " + + "consult a workspace-only charset override that does not apply outside " + + "Eclipse.") + .contains(""); + } + } + + @Test + void testLoad_whenTheDtdFileDoesNotExist_logsAWarningWithTheReason() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace(); + LogRecorder log = new LogRecorder(EditorInputDtdSource.class)) + { + final IFile datasetFile = workspace.createFile("dataset.xml", ""); + final EditorInputDtdSource source = new EditorInputDtdSource(fileInput(datasetFile)); + + source.load(null, "does-not-exist.dtd"); + + assertThat(log.statuses()).as("A DTD that cannot be read must be logged once.").hasSize(1); + final IStatus warning = log.statuses().get(0); + assertThat(warning).as("The warning must name the DTD.").usingRecursiveComparison() + .ignoringFields("exception").isEqualTo(new Status(IStatus.WARNING, + "org.dbunit.eclipse.dataset.ui", "The DTD \"does-not-exist.dtd\" could not be read.", + null)); + assertThat(warning.getException()).as("The warning must carry the reason the read failed.") + .isInstanceOf(IOException.class); + } + } + + @Test + void testLoad_whenTheSystemIdCannotBeResolved_logsAWarningWithTheReason() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace(); + LogRecorder log = new LogRecorder(EditorInputDtdSource.class)) + { + final IFile datasetFile = workspace.createFile("dataset.xml", ""); + final EditorInputDtdSource source = new EditorInputDtdSource(fileInput(datasetFile)); + + source.load(null, "file:///dtd/dataset.dtd?x=1"); + + assertThat(log.statuses()).as("A system ID that cannot be resolved must be logged once.") + .hasSize(1); + final IStatus warning = log.statuses().get(0); + assertThat(warning).as("The warning must name the system ID.").usingRecursiveComparison() + .ignoringFields("exception").isEqualTo(new Status(IStatus.WARNING, + "org.dbunit.eclipse.dataset.ui", + "The DTD \"file:///dtd/dataset.dtd?x=1\" could not be read.", null)); + assertThat(warning.getException()).as("The warning must carry the reason the resolution failed.") + .isInstanceOf(IllegalArgumentException.class); + } + } + + @Test + void testLoad_whenSystemIdIsANetworkPathReference_refusesItBeforeReadingIt() throws Exception + { + assertRefusedBeforeReading("//dtd-host.invalid/share/dataset.dtd"); + } + + @Test + void testLoad_whenSystemIdIsAFileUriWithAHost_refusesItBeforeReadingIt() throws Exception + { + assertRefusedBeforeReading("file://dtd-host.invalid/share/dataset.dtd"); + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void testLoad_whenSystemIdIsAUncPath_refusesItBeforeReadingIt() throws Exception + { + assertRefusedBeforeReading("\\\\dtd-host.invalid\\share\\dataset.dtd"); + } + + @Test + @DisabledOnOs(OS.WINDOWS) + void testLoad_whenSystemIdLooksLikeAUncPathWhereABackslashIsNoSeparator_looksForAFileOfThatName() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace(); + LogRecorder log = new LogRecorder(EditorInputDtdSource.class)) + { + final IFile datasetFile = workspace.createFile("dataset.xml", ""); + final EditorInputDtdSource source = new EditorInputDtdSource(fileInput(datasetFile)); + + final Optional loaded = source.load(null, "\\\\dtd-host.invalid\\share\\dataset.dtd"); + + assertThat(loaded).as("No file of that name exists beside the dataset.").isEmpty(); + assertThat(log.statuses()).as("The missing file must be logged once.").hasSize(1); + assertThat(log.statuses().get(0).getException()) + .as("Where a backslash is an ordinary character, as the XML parser takes it, the text " + + "names a file beside the dataset, and nothing is on a network share.") + .isInstanceOf(NoSuchFileException.class); + } + } + + @Test + @DisabledOnOs(OS.WINDOWS) + void testLoad_whenTheRelativeSystemIdHasABackslashWhereItIsNoSeparator_readsTheFileWithThatName( + @TempDir final Path tempDir) throws Exception + { + Files.writeString(tempDir.resolve("dtd\\my.dtd"), ""); + final EditorInputDtdSource source = new EditorInputDtdSource( + fileStoreInput(EFS.getStore(tempDir.resolve("dataset.xml").toUri()))); + + final Optional loaded = source.load(null, "dtd\\my.dtd"); + + assertThat(loaded) + .as("Where a backslash is an ordinary character, a system ID with one names a file.") + .contains(""); + } + + @Test + @EnabledOnOs(OS.WINDOWS) + void testLoad_whenSystemIdIsAFileUriWhosePathIsAUncPath_refusesItBeforeReadingIt() throws Exception + { + assertRefusedBeforeReading("file:////dtd-host.invalid/share/dataset.dtd"); + } + + @Test + void testLoad_whenTheSameDtdFailsAgain_logsItOnlyOnce() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace(); + LogRecorder log = new LogRecorder(EditorInputDtdSource.class)) + { + final IFile datasetFile = workspace.createFile("dataset.xml", ""); + final EditorInputDtdSource source = new EditorInputDtdSource(fileInput(datasetFile)); + + source.load(null, "does-not-exist.dtd"); + source.load(null, "does-not-exist.dtd"); + + assertThat(log.statuses()) + .as("A DTD that stays unreadable must be logged once, not on every reload.").hasSize(1); + } + } + + @Test + void testLoad_whenTheDtdBecomesReadableAndThenFailsAgain_logsTheSecondFailure( + @TempDir final Path tempDir) throws Exception + { + final Path dtdPath = tempDir.resolve("my.dtd"); + try (UiTestWorkspace workspace = new UiTestWorkspace(); + LogRecorder log = new LogRecorder(EditorInputDtdSource.class)) + { + final IFile datasetFile = workspace.createFile("dataset.xml", ""); + final EditorInputDtdSource source = new EditorInputDtdSource(fileInput(datasetFile)); + source.load(null, dtdPath.toString()); + Files.writeString(dtdPath, ""); + source.load(null, dtdPath.toString()); + Files.delete(dtdPath); + + source.load(null, dtdPath.toString()); + + assertThat(log.statuses()).as("A failure after a successful load must be logged again.") + .hasSize(2); + } + } + + @Test + void testLoad_whenTheDtdIsRead_logsNothing() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace(); + LogRecorder log = new LogRecorder(EditorInputDtdSource.class)) + { + workspace.createFile("my.dtd", ""); + final IFile datasetFile = workspace.createFile("dataset.xml", ""); + final EditorInputDtdSource source = new EditorInputDtdSource(fileInput(datasetFile)); + + source.load(null, "my.dtd"); + + assertThat(log.statuses()).as("A DTD that is read must not be logged.").isEmpty(); + } + } + + /** + * Loads a system ID that names a host and asserts that the editor refused it before the file system + * opened it. Windows opens a UNC path with a connection to the host, which sends the user's + * credentials, and a read that failed after that would show as an IOException. + */ + private static void assertRefusedBeforeReading(final String systemId) throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace(); + LogRecorder log = new LogRecorder(EditorInputDtdSource.class)) + { + final IFile datasetFile = workspace.createFile("dataset.xml", ""); + final EditorInputDtdSource source = new EditorInputDtdSource(fileInput(datasetFile)); + + final Optional loaded = source.load(null, systemId); + + assertThat(loaded).as("A DTD on a network host must yield no DTD text.").isEmpty(); + assertThat(log.statuses()).as("A refused DTD must be logged once.").hasSize(1); + assertThat(log.statuses().get(0).getException()) + .as("The editor must refuse a network location itself, not hand it to the file system.") + .isInstanceOf(IllegalArgumentException.class); + } + } + + private static IEditorInput fileInput(final IFile file) + { + return minimalInput(file.getName(), adapterClass -> adapterClass == IFile.class ? file : null); + } + + private static IEditorInput fileStoreInput(final IFileStore store) + { + return minimalInput(store.getName(), + adapterClass -> adapterClass == IFileStore.class ? store : null); + } + + private static IEditorInput minimalInput(final String name, final Function, Object> adapters) + { + return new IEditorInput() + { + @Override + public boolean exists() + { + return true; + } + + @Override + public ImageDescriptor getImageDescriptor() + { + return ImageDescriptor.getMissingImageDescriptor(); + } + + @Override + public String getName() + { + return name; + } + + @Override + public IPersistableElement getPersistable() + { + return null; + } + + @Override + public String getToolTipText() + { + return name; + } + + @Override + public T getAdapter(final Class adapterClass) + { + return adapterClass.cast(adapters.apply(adapterClass)); + } + }; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/EncodingResolverTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/EncodingResolverTest.java new file mode 100644 index 00000000..ad1c64bd --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/EncodingResolverTest.java @@ -0,0 +1,119 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.Charset; +import java.nio.charset.IllegalCharsetNameException; +import java.nio.charset.StandardCharsets; +import java.nio.charset.UnsupportedCharsetException; + +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.Status; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link EncodingResolver}'s fallback to UTF-8 and the once-per-name log of an unsupported encoding. + */ +class EncodingResolverTest +{ + @Test + void testCharsetOf_whenTheEncodingIsSupported_returnsItsCharsetAndLogsNothing() + { + try (LogRecorder log = new LogRecorder(EncodingResolver.class)) + { + final Charset charset = new EncodingResolver().charsetOf("ISO-8859-1"); + + assertThat(charset).as("A supported encoding must resolve to its own charset.") + .isEqualTo(StandardCharsets.ISO_8859_1); + assertThat(log.statuses()).as("A supported encoding must not be logged.").isEmpty(); + } + } + + @Test + void testCharsetOf_whenTheEncodingIsUnsupported_returnsUtf8AndLogsAWarning() + { + try (LogRecorder log = new LogRecorder(EncodingResolver.class)) + { + final Charset charset = new EncodingResolver().charsetOf("no-such-charset"); + + assertThat(charset).as("An unsupported encoding must fall back to UTF-8.") + .isEqualTo(StandardCharsets.UTF_8); + assertThat(log.statuses()).as("An unsupported encoding must be logged once.").hasSize(1); + final IStatus warning = log.statuses().get(0); + assertThat(warning).as("The warning must name the encoding.").usingRecursiveComparison() + .ignoringFields("exception") + .isEqualTo(new Status(IStatus.WARNING, "org.dbunit.eclipse.dataset.ui", + "The encoding \"no-such-charset\" is not supported, so the dataset editor " + + "uses UTF-8 instead.", + null)); + assertThat(warning.getException()).as("The warning must carry the reason.") + .isInstanceOf(UnsupportedCharsetException.class); + } + } + + @Test + void testCharsetOf_whenTheEncodingNameIsIllegal_returnsUtf8AndLogsAWarning() + { + try (LogRecorder log = new LogRecorder(EncodingResolver.class)) + { + final Charset charset = new EncodingResolver().charsetOf("not a charset!"); + + assertThat(charset).as("An illegal encoding name must fall back to UTF-8.") + .isEqualTo(StandardCharsets.UTF_8); + assertThat(log.statuses()).as("An illegal encoding name must be logged once.").hasSize(1); + assertThat(log.statuses().get(0).getException()).as("The warning must carry the reason.") + .isInstanceOf(IllegalCharsetNameException.class); + } + } + + @Test + void testCharsetOf_whenTheSameUnsupportedEncodingIsAskedAgain_logsItOnlyOnce() + { + try (LogRecorder log = new LogRecorder(EncodingResolver.class)) + { + final EncodingResolver resolver = new EncodingResolver(); + + resolver.charsetOf("no-such-charset"); + resolver.charsetOf("no-such-charset"); + + assertThat(log.statuses()) + .as("An encoding that stays unsupported must be logged once, not on every request.") + .hasSize(1); + } + } + + @Test + void testCharsetOf_whenAnotherUnsupportedEncodingFollows_logsItToo() + { + try (LogRecorder log = new LogRecorder(EncodingResolver.class)) + { + final EncodingResolver resolver = new EncodingResolver(); + + resolver.charsetOf("no-such-charset"); + resolver.charsetOf("another-missing-charset"); + + assertThat(log.statuses()).as("A different unsupported encoding must be logged as well.") + .hasSize(2); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/FlatXmlDatasetEditorTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/FlatXmlDatasetEditorTest.java new file mode 100644 index 00000000..e5d2001a --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/FlatXmlDatasetEditorTest.java @@ -0,0 +1,1174 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.dbunit.eclipse.dataset.core.edit.CellChange; +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.dbunit.eclipse.dataset.core.model.DatasetModel; +import org.dbunit.eclipse.dataset.core.model.DatasetProblem; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.dbunit.eclipse.dataset.core.model.ProblemCode; +import org.dbunit.eclipse.dataset.ui.DatasetUiPlugin; +import org.dbunit.eclipse.dataset.ui.preferences.PreferenceKeys; +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IMarker; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.core.runtime.preferences.IEclipsePreferences; +import org.eclipse.core.runtime.preferences.InstanceScope; +import org.eclipse.jface.preference.IPreferenceStore; +import org.eclipse.jface.text.BadLocationException; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.ITextSelection; +import org.eclipse.nebula.widgets.nattable.NatTable; +import org.eclipse.nebula.widgets.nattable.config.CellConfigAttributes; +import org.eclipse.nebula.widgets.nattable.data.convert.IDisplayConverter; +import org.eclipse.nebula.widgets.nattable.edit.command.EditSelectionCommand; +import org.eclipse.nebula.widgets.nattable.grid.GridRegion; +import org.eclipse.nebula.widgets.nattable.layer.event.ILayerEvent; +import org.eclipse.nebula.widgets.nattable.layer.event.VisualRefreshEvent; +import org.eclipse.nebula.widgets.nattable.style.DisplayMode; +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.CTabItem; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.ui.IEditorInput; +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.IWorkbenchPage; +import org.eclipse.ui.IWorkbenchWindow; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.ide.IDE; +import org.eclipse.ui.ide.IGotoMarker; +import org.eclipse.ui.texteditor.AbstractTextEditor; +import org.eclipse.ui.texteditor.ITextEditor; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link FlatXmlDatasetEditor} against the Editor Structure lifecycle rules and its reaction to + * preference changes and to a file that changed outside the workbench. Some of the tests work end to end + * through a real editor and show that the Tables page makes the DTD declarations of a dataset visible and + * follows a DTD that changed. + */ +class FlatXmlDatasetEditorTest +{ + private static final int TABLES_PAGE_INDEX = 0; + + private static final int SOURCE_PAGE_INDEX = 1; + + private static final String SAVED_USERS = ""; + + private static final String INVALID_VALUE = "a" + (char) 1 + "b"; + + private static final String UTF8_DECLARED_USERS = + ""; + + private static final String CHANGED_USERS = ""; + + private static final String OTHER_DATASET = ""; + + @AfterEach + void restoreDefaultPreferences() + { + final IPreferenceStore store = preferenceStore(); + store.setToDefault(PreferenceKeys.NULL_DISPLAY_TEXT); + store.setToDefault(PreferenceKeys.ASSUME_COLUMN_SENSING); + store.setToDefault(PreferenceKeys.CASE_SENSITIVE_TABLE_NAMES); + final IEclipsePreferences resourcePreferences = + InstanceScope.INSTANCE.getNode(ResourcesPlugin.PI_RESOURCES); + resourcePreferences.remove(ResourcesPlugin.PREF_LIGHTWEIGHT_AUTO_REFRESH); + } + + @Test + void testOpen_whenFileIsFlatXml_showsTablesAndSourcePages() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", ""); + + final IEditorPart editor = workspace.open(file); + + assertThat(editor).as("A flat XML file must open in the dataset editor.") + .isInstanceOf(FlatXmlDatasetEditor.class); + final FlatXmlDatasetEditor datasetEditor = (FlatXmlDatasetEditor) editor; + assertThat(datasetEditor.getPageTitle(TABLES_PAGE_INDEX)).as("Page 0 must be Tables.") + .isEqualTo("Tables"); + assertThat(datasetEditor.getPageTitle(SOURCE_PAGE_INDEX)).as("Page 1 must be Source.") + .isEqualTo("Source"); + } + } + + @Test + void testCurrentCharset_whenXmlDeclaresIso88591_returnsThatCharset() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final String content = ""; + final IFile file = workspace.createFile("dataset.xml", content, StandardCharsets.ISO_8859_1); + + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + + assertThat(editor.currentCharset()) + .as("The XML declaration's encoding must become the editor's charset.") + .isEqualTo(StandardCharsets.ISO_8859_1); + } + } + + @Test + void testCurrentCharset_whenTheDeclarationWasEditedAfterOpening_returnsTheCharsetThatItNamesNow() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", UTF8_DECLARED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + + replaceInDocument(editor, "UTF-8", "ISO-8859-1"); + + assertThat(editor.currentCharset()) + .as("A save writes the text in the encoding that its declaration names when it is saved, " + + "not in the one that it named when the file was loaded.") + .isEqualTo(StandardCharsets.ISO_8859_1); + } + } + + @Test + void testCurrentCharset_whenTheFileHasACharsetOfItsOwn_returnsItWhateverTheDeclarationNames() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", UTF8_DECLARED_USERS); + file.setCharset("ISO-8859-1", null); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + + assertThat(editor.currentCharset()) + .as("A save writes a file that has a charset of its own in that charset, whatever its " + + "declaration names.") + .isEqualTo(StandardCharsets.ISO_8859_1); + } + } + + @Test + void testSetCells_whenTheDeclarationWasEditedToAnEncodingThatLacksTheCharacter_writesAReferenceThatSaves() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace(); + MessageDialogDriver dialogDriver = new MessageDialogDriver()) + { + dialogDriver.expectNoDialog(); + final IFile file = workspace.createFile("dataset.xml", UTF8_DECLARED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + replaceInDocument(editor, "UTF-8", "ISO-8859-1"); + + editor.getDatasetDocument().setCells("USERS", List.of(new CellChange(0, "NAME", "\u20AC"))); + UiTestWorkspace.processEvents(); + + final String expectedText = "" + + ""; + assertThat(documentText(editor)).as("The euro sign is not in ISO-8859-1, so the grid edit must " + + "write it as a character reference.").isEqualTo(expectedText); + editor.doSave(new NullProgressMonitor()); + UiTestWorkspace.processEvents(); + assertThat(dialogDriver.unexpectedDialogTitles()) + .as("The save must not fail on a character that its encoding cannot hold.").isEmpty(); + assertThat(editor.isDirty()).as("Saving must clear the dirty state.").isFalse(); + try (InputStream contents = file.getContents()) + { + assertThat(new String(contents.readAllBytes(), StandardCharsets.ISO_8859_1)) + .as("The saved file must hold the text.").isEqualTo(expectedText); + } + } + } + + @Test + void testContentType_whenFileIsNotADataset_isNeitherTheFlatXmlTypeNorTheDefaultEditor() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + // Not opened: without an XML editor in the test runtime, Eclipse may launch an external + // program for the resolved default editor. + final IFile file = workspace.createFile("other.xml", ""); + + final String contentTypeId = file.getContentDescription().getContentType().getId(); + + assertThat(contentTypeId).as("A non-dataset XML file must not get the flat XML content type.") + .isNotEqualTo("org.dbunit.eclipse.dataset.flatxml"); + final var defaultEditor = IDE.getDefaultEditor(file); + assertThat(defaultEditor == null || !FlatXmlDatasetEditor.ID.equals(defaultEditor.getId())) + .as("The dataset editor must not be the default editor for a non-dataset XML file.") + .isTrue(); + } + } + + @Test + void testSourceEdit_whenDocumentChanges_makesEditorDirtyAndDoSaveWritesTheFile() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final ITextEditor sourceEditor = editor.getSourceEditor(); + + sourceEditor.getDocumentProvider().getDocument(sourceEditor.getEditorInput()).replace(0, 0, + ""); + UiTestWorkspace.processEvents(); + assertThat(editor.isDirty()).as("A source edit must make the editor dirty.").isTrue(); + + editor.doSave(new NullProgressMonitor()); + UiTestWorkspace.processEvents(); + + assertThat(editor.isDirty()).as("Saving must clear the dirty state.").isFalse(); + final String written; + try (InputStream contents = file.getContents()) + { + written = new String(contents.readAllBytes(), StandardCharsets.UTF_8); + } + assertThat(written).as("The saved file must contain the source edit.") + .isEqualTo(""); + } + } + + @Test + void testDoSave_whenACellEditorKeepsAValueThatFailedValidation_writesNothingAndCancelsTheMonitor() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace(); + MessageDialogDriver dialogDriver = new MessageDialogDriver()) + { + final IFile file = workspace.createFile("dataset.xml", SAVED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + makeDirty(editor); + openCellEditorHolding(editor, INVALID_VALUE); + dialogDriver.pressButtonOfNextDialog(MessageDialogDriver.CHANGE_BUTTON_OF_VALIDATION_DIALOG); + final NullProgressMonitor monitor = new NullProgressMonitor(); + + editor.doSave(monitor); + UiTestWorkspace.processEvents(); + + assertThat(dialogDriver.hasHandledDialog()).as("The invalid value must be reported.").isTrue(); + assertThat(monitor.isCanceled()) + .as("A save that cannot take the value of the open cell editor must be canceled, so that " + + "closing the editor stops as well.") + .isTrue(); + assertThat(fileText(file)).as("Nothing may be written.").isEqualTo(SAVED_USERS); + assertThat(editor.isDirty()).as("The changes must stay unsaved.").isTrue(); + assertThat(editor.getTablesPage().hasActiveCellEditor()) + .as("The cell editor stays open, so that the value can be changed.").isTrue(); + } + } + + @Test + void testDoSave_whenTheValueThatFailedValidationIsDiscarded_savesTheDocument() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace(); + MessageDialogDriver dialogDriver = new MessageDialogDriver()) + { + final IFile file = workspace.createFile("dataset.xml", SAVED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + makeDirty(editor); + openCellEditorHolding(editor, INVALID_VALUE); + dialogDriver.pressButtonOfNextDialog(MessageDialogDriver.DISCARD_BUTTON_OF_VALIDATION_DIALOG); + final NullProgressMonitor monitor = new NullProgressMonitor(); + + editor.doSave(monitor); + UiTestWorkspace.processEvents(); + + assertThat(monitor.isCanceled()).as("Nothing is left to hold the save back.").isFalse(); + assertThat(fileText(file)).as("The document without the discarded value must be written.") + .isEqualTo("" + SAVED_USERS); + assertThat(editor.isDirty()).as("Saving must clear the dirty state.").isFalse(); + assertThat(editor.getTablesPage().hasActiveCellEditor()) + .as("Discarding the value closes the cell editor.").isFalse(); + } + } + + @Test + void testDoSaveAs_whenACellEditorKeepsAValueThatFailedValidation_asksForNoFile() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace(); + MessageDialogDriver dialogDriver = new MessageDialogDriver()) + { + final IFile file = workspace.createFile("dataset.xml", SAVED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + makeDirty(editor); + openCellEditorHolding(editor, INVALID_VALUE); + dialogDriver.pressButtonOfNextDialog(MessageDialogDriver.CHANGE_BUTTON_OF_VALIDATION_DIALOG); + + editor.doSaveAs(); + UiTestWorkspace.processEvents(); + + assertThat(dialogDriver.unexpectedDialogTitles()) + .as("Save As must not ask for a file while the value of the open cell editor is " + + "invalid.") + .isEmpty(); + assertThat(editor.getTablesPage().hasActiveCellEditor()) + .as("The cell editor stays open, so that the value can be changed.").isTrue(); + } + } + + @Test + void testShowOnSourcePage_whenACellEditorKeepsAValueThatFailedValidation_staysOnTheTablesPage() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace(); + MessageDialogDriver dialogDriver = new MessageDialogDriver()) + { + final IFile file = workspace.createFile("dataset.xml", SAVED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + openCellEditorHolding(editor, INVALID_VALUE); + dialogDriver.pressButtonOfEveryDialog(MessageDialogDriver.CHANGE_BUTTON_OF_VALIDATION_DIALOG); + + editor.showOnSourcePage(0, 0); + UiTestWorkspace.processEvents(); + + assertThat(editor.getActivePage()) + .as("The Tables page must stay, because leaving it would leave its cell editor open with " + + "a value that is not accepted.") + .isEqualTo(TABLES_PAGE_INDEX); + assertThat(editor.getTablesPage().isActive()) + .as("A page change that is refused leaves the Tables page the page that is active.") + .isTrue(); + assertThat(documentText(editor)).as("The value that is not accepted must not reach the document.") + .isEqualTo(SAVED_USERS); + assertThat(dialogDriver.unexpectedDialogTitles()) + .as("No dialog but the one about the invalid value may open.").isEmpty(); + } + } + + @Test + void testShowOnSourcePage_whenTheValueThatFailedValidationIsDiscarded_showsTheSourcePage() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace(); + MessageDialogDriver dialogDriver = new MessageDialogDriver()) + { + final IFile file = workspace.createFile("dataset.xml", SAVED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + openCellEditorHolding(editor, INVALID_VALUE); + dialogDriver.pressButtonOfNextDialog(MessageDialogDriver.DISCARD_BUTTON_OF_VALIDATION_DIALOG); + + editor.showOnSourcePage(0, 0); + UiTestWorkspace.processEvents(); + + assertThat(editor.getActivePage()).as("With the value discarded, nothing holds the page back.") + .isEqualTo(SOURCE_PAGE_INDEX); + assertThat(editor.getTablesPage().hasActiveCellEditor()) + .as("Discarding the value closes the cell editor.").isFalse(); + } + } + + @Test + void testOpen_whenFileHasAnXmlError_opensOnTheSourcePage() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = + workspace.createFile("broken.xml", ""); + + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + + assertThat(editor.getActivePage()) + .as("A file with an XML error must open on the Source page.") + .isEqualTo(SOURCE_PAGE_INDEX); + } + } + + @Test + void testOpen_whenFileIsBlank_opensOnTheTablesPage() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("blank.xml", ""); + + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.openInDatasetEditor(file); + + assertThat(editor.getActivePage()) + .as("A blank file has no error, and its Tables page offers to create the empty dataset.") + .isEqualTo(TABLES_PAGE_INDEX); + } + } + + @Test + void testOpen_whenFileHasContentThatIsNotADataset_opensOnTheSourcePage() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("other.xml", ""); + + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.openInDatasetEditor(file); + + assertThat(editor.getActivePage()) + .as("A file with content whose root is not a dataset must open on the Source page, " + + "unlike a blank one.") + .isEqualTo(SOURCE_PAGE_INDEX); + } + } + + @Test + void testOpen_whenDoctypeSystemIdClimbsAboveTheWorkspaceRoot_opensOnTheTablesPage() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", + ""); + + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + + assertThat(editor.getActivePage()) + .as("A DOCTYPE system ID that climbs above the workspace root must not keep the " + + "editor from opening on the Tables page.") + .isEqualTo(TABLES_PAGE_INDEX); + } + } + + @Test + void testFileDeleted_whenEditorIsClean_closesTheEditor() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", ""); + final IEditorPart editor = workspace.open(file); + final IEditorInput input = editor.getEditorInput(); + final IWorkbenchPage page = + PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(); + + file.delete(true, null); + UiTestWorkspace.processEvents(); + + assertThat(page.findEditor(input)).as("Deleting a clean file must close its editor.") + .isNull(); + } + } + + @Test + void testFileDeleted_whenEditorIsDirty_keepsTheEditorOpen() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final IEditorInput input = editor.getEditorInput(); + final IWorkbenchPage page = + PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(); + makeDirty(editor); + + file.delete(true, null); + UiTestWorkspace.processEvents(); + + assertThat(page.findEditor(input)) + .as("Deleting the file of an editor with unsaved changes must leave the editor open, " + + "so that the changes can still be saved.") + .isNotNull(); + } + } + + @Test + void testFileDeleted_whenEditorIsDirtyAndTheUserChoosesClose_closesTheEditorWithoutSaving() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace(); + MessageDialogDriver dialogDriver = new MessageDialogDriver()) + { + final IFile file = workspace.createFile("dataset.xml", ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final IEditorInput input = editor.getEditorInput(); + final IWorkbenchPage page = + PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(); + editor.getSourceEditor().checkExternalModification(); + workspace.open(workspace.createFile("other.xml", "")); + makeDirty(editor); + file.delete(true, null); + UiTestWorkspace.processEvents(); + dialogDriver.pressButtonOfNextDialog(MessageDialogDriver.CLOSE_BUTTON_OF_DELETED_FILE_DIALOG); + + page.activate(editor); + UiTestWorkspace.processEvents(); + + assertThat(dialogDriver.hasHandledDialog()) + .as("Activating an editor whose file was deleted must ask whether to save or close.") + .isTrue(); + assertThat(dialogDriver.unexpectedDialogTitles()) + .as("Choosing Close must not lead to another dialog.").isEmpty(); + assertThat(page.findEditor(input)).as("Choosing Close must close the editor.").isNull(); + assertThat(file.exists()).as("Closing without saving must not bring the deleted file back.") + .isFalse(); + } + } + + @Test + void testFileChanged_whenEditorIsDirtyOnTheTablesPageAndTheUserChoosesReplace_replacesTheText() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace(); + MessageDialogDriver dialogDriver = new MessageDialogDriver()) + { + final IFile file = workspace.createFile("dataset.xml", SAVED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final IWorkbenchPage page = activePage(); + workspace.open(workspace.createFile("other.xml", OTHER_DATASET)); + makeDirty(editor); + UiTestWorkspace.changeOnDisk(file, CHANGED_USERS); + dialogDriver.pressButtonOfNextDialog(MessageDialogDriver.REPLACE_BUTTON_OF_CHANGED_FILE_DIALOG); + + page.activate(editor); + UiTestWorkspace.processEvents(); + + assertThat(dialogDriver.hasHandledDialog()) + .as("Activating an editor with unsaved changes whose file changed on disk must ask " + + "whether to replace the text, although the Tables page is shown.") + .isTrue(); + assertThat(dialogDriver.unexpectedDialogTitles()) + .as("Choosing Replace must not lead to another dialog.").isEmpty(); + assertThat(documentText(editor)).as("Replace must give the editor the text of the file.") + .isEqualTo(CHANGED_USERS); + assertThat(editor.isDirty()).as("An editor with the text of its file is not dirty.").isFalse(); + assertThat(nameOfFirstUser(editor)).as("The grid must show the file's change.") + .isEqualTo("Zed"); + } + } + + @Test + void testFileChanged_whenEditorIsDirtyOnTheTablesPageAndTheUserChoosesIgnore_keepsTheTextAndAsksOnlyOnce() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace(); + MessageDialogDriver dialogDriver = new MessageDialogDriver()) + { + final IFile file = workspace.createFile("dataset.xml", SAVED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final IWorkbenchPage page = activePage(); + final IEditorPart other = workspace.open(workspace.createFile("other.xml", OTHER_DATASET)); + makeDirty(editor); + final String unsavedText = documentText(editor); + UiTestWorkspace.changeOnDisk(file, CHANGED_USERS); + dialogDriver.pressButtonOfNextDialog(MessageDialogDriver.IGNORE_BUTTON_OF_CHANGED_FILE_DIALOG); + + page.activate(editor); + UiTestWorkspace.processEvents(); + + assertThat(dialogDriver.hasHandledDialog()) + .as("Activating an editor with unsaved changes whose file changed on disk must ask " + + "whether to replace the text.") + .isTrue(); + assertThat(documentText(editor)).as("Ignoring the change must keep the unsaved text.") + .isEqualTo(unsavedText); + assertThat(editor.isDirty()).as("Ignoring the change must keep the unsaved changes.").isTrue(); + + dialogDriver.expectNoDialog(); + page.activate(other); + page.activate(editor); + UiTestWorkspace.processEvents(); + + assertThat(dialogDriver.unexpectedDialogTitles()) + .as("An ignored change must not be asked about again at the next activation.").isEmpty(); + } + } + + @Test + void testFileChanged_whenEditorIsDirtyOnTheSourcePageAndTheUserChoosesReplace_asksOnceAndReplacesTheText() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace(); + MessageDialogDriver dialogDriver = new MessageDialogDriver()) + { + final IFile file = workspace.createFile("dataset.xml", SAVED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final IWorkbenchPage page = activePage(); + final IEditorPart other = workspace.open(workspace.createFile("other.xml", OTHER_DATASET)); + editor.showOnSourcePage(0, 0); + page.activate(editor); + UiTestWorkspace.processEvents(); + page.activate(other); + makeDirty(editor); + UiTestWorkspace.changeOnDisk(file, CHANGED_USERS); + dialogDriver.pressButtonOfNextDialog(MessageDialogDriver.REPLACE_BUTTON_OF_CHANGED_FILE_DIALOG); + + page.activate(editor); + UiTestWorkspace.processEvents(); + + assertThat(dialogDriver.hasHandledDialog()) + .as("Activating an editor with unsaved changes whose file changed on disk must ask " + + "whether to replace the text on the Source page too.") + .isTrue(); + assertThat(dialogDriver.unexpectedDialogTitles()) + .as("The change must be asked about once, not by two checks.").isEmpty(); + assertThat(documentText(editor)).as("Replace must give the editor the text of the file.") + .isEqualTo(CHANGED_USERS); + } + } + + @Test + void testFileChanged_whenEditorIsCleanAndItsFileIsOutsideTheWorkspace_asksAndReplacesTheText() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace(); + MessageDialogDriver dialogDriver = new MessageDialogDriver()) + { + final Path file = workspace.createExternalFile(SAVED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.openExternalFile(file); + final IWorkbenchPage page = activePage(); + workspace.open(workspace.createFile("other.xml", OTHER_DATASET)); + UiTestWorkspace.changeOnDisk(file, CHANGED_USERS); + dialogDriver.pressButtonOfNextDialog(MessageDialogDriver.REPLACE_BUTTON_OF_CHANGED_FILE_DIALOG); + + page.activate(editor); + UiTestWorkspace.processEvents(); + + assertThat(dialogDriver.hasHandledDialog()) + .as("The workspace does not refresh a file outside it, so activating its editor must " + + "ask whether to replace the text with the changed file's.") + .isTrue(); + assertThat(documentText(editor)).as("Replace must give the editor the text of the file.") + .isEqualTo(CHANGED_USERS); + assertThat(nameOfFirstUser(editor)).as("The grid must show the file's change.") + .isEqualTo("Zed"); + } + } + + @Test + void testFileChanged_whenEditorIsCleanAndTheWorkspaceRefreshesItsFiles_doesNotAsk() throws Exception + { + setLightweightAutoRefresh(true); + try (UiTestWorkspace workspace = new UiTestWorkspace(); + MessageDialogDriver dialogDriver = new MessageDialogDriver()) + { + final IFile file = workspace.createFile("dataset.xml", SAVED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final IWorkbenchPage page = activePage(); + workspace.open(workspace.createFile("other.xml", OTHER_DATASET)); + UiTestWorkspace.changeOnDisk(file, CHANGED_USERS); + dialogDriver.expectNoDialog(); + + page.activate(editor); + UiTestWorkspace.processEvents(); + + assertThat(dialogDriver.unexpectedDialogTitles()) + .as("The workspace reloads the clean editor of one of its files by itself, so " + + "activating the editor must not ask.") + .isEmpty(); + } + } + + @Test + void testFileChanged_whenEditorIsCleanAndTheWorkspaceDoesNotRefreshItsFiles_asksAndReplacesTheText() + throws Exception + { + setLightweightAutoRefresh(false); + try (UiTestWorkspace workspace = new UiTestWorkspace(); + MessageDialogDriver dialogDriver = new MessageDialogDriver()) + { + final IFile file = workspace.createFile("dataset.xml", SAVED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final IWorkbenchPage page = activePage(); + workspace.open(workspace.createFile("other.xml", OTHER_DATASET)); + UiTestWorkspace.changeOnDisk(file, CHANGED_USERS); + dialogDriver.pressButtonOfNextDialog(MessageDialogDriver.REPLACE_BUTTON_OF_CHANGED_FILE_DIALOG); + + page.activate(editor); + UiTestWorkspace.processEvents(); + + assertThat(dialogDriver.hasHandledDialog()) + .as("Nothing reloads the clean editor of a file that the workspace does not refresh, " + + "so activating the editor must ask whether to replace the text.") + .isTrue(); + assertThat(documentText(editor)).as("Replace must give the editor the text of the file.") + .isEqualTo(CHANGED_USERS); + } + } + + @Test + void testGetAdapter_forAbstractTextEditorOnTheTablesPage_returnsTheSourceEditor() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", SAVED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + assertThat(editor.getActivePage()).as("An editable dataset must start on the Tables page.") + .isEqualTo(TABLES_PAGE_INDEX); + + final AbstractTextEditor textEditor = editor.getAdapter(AbstractTextEditor.class); + + assertThat(textEditor) + .as("The Source editor treats the part that answers with it as its own when it checks " + + "its file for changes, so the dataset editor must answer on every page.") + .isSameAs(editor.getSourceEditor()); + } + } + + @Test + void testGetAdapter_forIGotoMarker_leavesTheTablesPageActive() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + assertThat(editor.getActivePage()).as("An editable dataset must start on the Tables page.") + .isEqualTo(TABLES_PAGE_INDEX); + + final IGotoMarker gotoMarker = editor.getAdapter(IGotoMarker.class); + + assertThat(gotoMarker).as("IGotoMarker must be available from the source editor.") + .isNotNull(); + assertThat(editor.getActivePage()) + .as("Getting the IGotoMarker adapter must not switch pages by itself.") + .isEqualTo(TABLES_PAGE_INDEX); + } + } + + @Test + void testSelectAndReveal_throughTheTextEditorAdapterOnTheTablesPage_showsTheSourcePageAndSelectsTheRange() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", SAVED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final ITextEditor textEditor = editor.getAdapter(ITextEditor.class); + final int offset = SAVED_USERS.indexOf("USERS"); + + textEditor.selectAndReveal(offset, "USERS".length()); + UiTestWorkspace.processEvents(); + + assertThat(editor.getActivePage()) + .as("A search result or a console link that selects text through the text editor " + + "adapter must show it, so the Source page must come forward.") + .isEqualTo(SOURCE_PAGE_INDEX); + final ITextSelection selection = + (ITextSelection) editor.getSourceEditor().getSelectionProvider().getSelection(); + assertThat(selection.getText()).as("The requested range must be the selection.") + .isEqualTo("USERS"); + } + } + + @Test + void testShowSourcePage_beforeThePagesAreCreated_doesNothing() + { + final FlatXmlDatasetEditor editor = new FlatXmlDatasetEditor(); + + assertThatCode(editor::showSourcePage) + .as("Text that is selected while the editor is set up has no page to bring forward yet.") + .doesNotThrowAnyException(); + } + + @Test + void testSelectAndReveal_whenTheSourcePageIsActive_keepsItActive() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", SAVED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + editor.showOnSourcePage(0, 0); + final ITextEditor textEditor = editor.getAdapter(ITextEditor.class); + + textEditor.selectAndReveal(1, 7); + UiTestWorkspace.processEvents(); + + assertThat(editor.getActivePage()).as("The Source page stays the active page.") + .isEqualTo(SOURCE_PAGE_INDEX); + } + } + + @Test + void testGotoMarker_withAMarkerOnTheFile_activatesTheSourcePageAndSelectsItsRange() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final IGotoMarker gotoMarker = editor.getAdapter(IGotoMarker.class); + final IMarker marker = file.createMarker(IMarker.TEXT); + marker.setAttribute(IMarker.CHAR_START, 1); + marker.setAttribute(IMarker.CHAR_END, 8); + + gotoMarker.gotoMarker(marker); + UiTestWorkspace.processEvents(); + + assertThat(editor.getActivePage()).as("Going to a marker must activate the Source page.") + .isEqualTo(SOURCE_PAGE_INDEX); + final ITextSelection selection = + (ITextSelection) editor.getSourceEditor().getSelectionProvider().getSelection(); + assertThat(selection.getOffset()).as("Going to a marker must select its range's start.") + .isEqualTo(1); + assertThat(selection.getLength()).as("Going to a marker must select its range's length.") + .isEqualTo(7); + } + } + + @Test + void testPreferenceChange_ofCaseSensitiveTableNames_regroupsTheTablesOfTheOpenEditor() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", + ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + assertThat(tabItems(editor)).extracting(CTabItem::getText) + .as("By default, table names that differ only in case must be one table.") + .containsExactly("USERS"); + + preferenceStore().setValue(PreferenceKeys.CASE_SENSITIVE_TABLE_NAMES, true); + UiTestWorkspace.processEvents(); + + assertThat(tabItems(editor)).extracting(CTabItem::getText) + .as("Case-sensitive table names must split the open editor's table in two.") + .containsExactly("USERS", "users"); + + preferenceStore().setValue(PreferenceKeys.CASE_SENSITIVE_TABLE_NAMES, false); + UiTestWorkspace.processEvents(); + + assertThat(tabItems(editor)).extracting(CTabItem::getText) + .as("Case-insensitive table names must merge the open editor's tables again.") + .containsExactly("USERS"); + } + } + + @Test + void testPreferenceChange_ofAssumeColumnSensing_revalidatesTheOpenEditor() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", + ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + assertThat(problems(editor)).extracting(DatasetProblem::code) + .as("Without column sensing, a column missing from the first row must be reported.") + .contains(ProblemCode.COLUMN_NOT_IN_FIRST_ROW); + + preferenceStore().setValue(PreferenceKeys.ASSUME_COLUMN_SENSING, true); + UiTestWorkspace.processEvents(); + + assertThat(problems(editor)).extracting(DatasetProblem::code) + .as("With column sensing, dbUnit reads the column, so the editor must stop reporting it.") + .doesNotContain(ProblemCode.COLUMN_NOT_IN_FIRST_ROW); + } + } + + @Test + void testPreferenceChange_ofNullDisplayText_repaintsTheGridsWithTheNewText() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", + ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final NatTable natTable = (NatTable) tabItems(editor).get(0).getControl(); + final List events = new ArrayList<>(); + natTable.addLayerListener(events::add); + + preferenceStore().setValue(PreferenceKeys.NULL_DISPLAY_TEXT, ""); + UiTestWorkspace.processEvents(); + + assertThat(events).as("Changing the NULL display text must repaint the open grids.") + .anyMatch(VisualRefreshEvent.class::isInstance); + final IDisplayConverter converter = natTable.getConfigRegistry().getConfigAttribute( + CellConfigAttributes.DISPLAY_CONVERTER, DisplayMode.NORMAL, GridRegion.BODY); + assertThat(converter.canonicalToDisplayValue(null)) + .as("The repainted grids must show NULL cells with the new text.").isEqualTo(""); + } + } + + @Test + void testPreferenceChange_onABackgroundThread_regroupsTheTablesOnTheUiThread() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", + ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + + final Thread thread = new Thread( + () -> preferenceStore().setValue(PreferenceKeys.CASE_SENSITIVE_TABLE_NAMES, true)); + thread.start(); + thread.join(); + + assertThat(editor.getDatasetDocument().getModel().getTables()) + .as("A change on a background thread must not touch the editor on that thread.") + .hasSize(1); + + UiTestWorkspace.processEvents(); + + assertThat(tabItems(editor)).extracting(CTabItem::getText) + .as("The UI thread must then regroup the open editor's tables.") + .containsExactly("USERS", "users"); + } + } + + private static void makeDirty(final FlatXmlDatasetEditor editor) throws BadLocationException + { + final ITextEditor sourceEditor = editor.getSourceEditor(); + sourceEditor.getDocumentProvider().getDocument(sourceEditor.getEditorInput()).replace(0, 0, + ""); + UiTestWorkspace.processEvents(); + assertThat(editor.isDirty()).as("A source edit must make the editor dirty.").isTrue(); + } + + private static void replaceInDocument(final FlatXmlDatasetEditor editor, final String oldText, + final String newText) throws BadLocationException + { + final ITextEditor sourceEditor = editor.getSourceEditor(); + final IEditorInput input = sourceEditor.getEditorInput(); + final IDocument document = sourceEditor.getDocumentProvider().getDocument(input); + final int offset = document.get().indexOf(oldText); + document.replace(offset, oldText.length(), newText); + UiTestWorkspace.processEvents(); + } + + private static void openCellEditorHolding(final FlatXmlDatasetEditor editor, final String value) + { + final TablesPage tablesPage = editor.getTablesPage(); + final Composite page = (Composite) tablesPage.getControl(); + page.setSize(800, 600); + page.layout(true, true); + UiTestWorkspace.processEvents(); + final NatTable natTable = (NatTable) tablesPage.getTabFolder().getSelection().getControl(); + tablesPage.selectRegion(1, 1, 1, 1); + natTable.doCommand(new EditSelectionCommand(natTable, natTable.getConfigRegistry())); + natTable.getActiveCellEditor().setEditorValue(value); + } + + private static String fileText(final IFile file) throws CoreException, IOException + { + try (InputStream contents = file.getContents()) + { + return new String(contents.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static void setLightweightAutoRefresh(final boolean enabled) + { + final IEclipsePreferences resourcePreferences = + InstanceScope.INSTANCE.getNode(ResourcesPlugin.PI_RESOURCES); + resourcePreferences.putBoolean(ResourcesPlugin.PREF_LIGHTWEIGHT_AUTO_REFRESH, enabled); + } + + private static String documentText(final FlatXmlDatasetEditor editor) + { + final ITextEditor sourceEditor = editor.getSourceEditor(); + return sourceEditor.getDocumentProvider().getDocument(sourceEditor.getEditorInput()).get(); + } + + private static String nameOfFirstUser(final FlatXmlDatasetEditor editor) + { + final DatasetTable users = editor.getDatasetDocument().getModel().getTables().get(0); + final int nameColumn = users.getColumnIndex("NAME"); + return users.getEffectiveValue(0, nameColumn); + } + + private static IWorkbenchPage activePage() + { + return PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(); + } + + private static IPreferenceStore preferenceStore() + { + return DatasetUiPlugin.getDefault().getPreferenceStore(); + } + + private static List tabItems(final FlatXmlDatasetEditor editor) + { + return List.of(editor.getTablesPage().getTabFolder().getItems()); + } + + private static List problems(final FlatXmlDatasetEditor editor) + { + return editor.getDatasetDocument().getModel().getProblems(); + } + + @Test + void testTablesPage_withAnInternalDtdSubset_showsDeclaredColumnsInOrderAndADeclaredOnlyTable() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", + "\n\n" + + "\n" + + "\n\n]>\n" + + "\n \n\n"); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final TablesPage tablesPage = editor.getTablesPage(); + final DatasetModel model = editor.getDatasetDocument().getModel(); + + assertThat(tablesPage.getTabFolder().getItemCount()) + .as("Both the real and the declared-only table must get a tab.").isEqualTo(2); + + final DatasetTable usersTable = model.findTable("USERS").orElseThrow(); + assertThat(usersTable.getColumns()).as("Declared columns must appear in DTD order.") + .containsExactly(new DatasetColumn("ID", true, true, false), + new DatasetColumn("NAME", true, false, false)); + assertThat(usersTable.isDeclaredOnly()) + .as("A table with an element in the document is not declared-only.").isFalse(); + + final DatasetTable ordersTable = model.findTable("ORDERS").orElseThrow(); + assertThat(ordersTable.isDeclaredOnly()) + .as("A table with no elements in the document must be declared-only.").isTrue(); + + final CTabItem ordersTab = tabForTable(tablesPage, "ORDERS"); + assertThat(ordersTab.getToolTipText()) + .as("A declared-only table's tab must explain why it has no rows.") + .isEqualTo("Declared in the DTD; no rows"); + assertThat(ordersTab.getFont().getFontData()[0].getStyle() & SWT.ITALIC) + .as("A declared-only table's tab must be italic.").isEqualTo(SWT.ITALIC); + } + } + + @Test + void testHandleWindowActivated_whenAnotherWindowIsActivated_doesNotReloadTheDtd() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final FlatXmlDatasetEditor editor = openWithDtdThatThenGainsAColumn(workspace); + + editor.handleWindowActivated(anotherWindow()); + UiTestWorkspace.processEvents(); + + assertThat(columnNamesOfUsers(editor)) + .as("Another window that is activated must not make this editor read its files.") + .containsExactly("ID"); + } + } + + @Test + void testHandleWindowActivated_whenTheWindowOfTheEditorIsActivated_reloadsTheDtd() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final FlatXmlDatasetEditor editor = openWithDtdThatThenGainsAColumn(workspace); + + editor.handleWindowActivated(editor.getEditorSite().getWorkbenchWindow()); + UiTestWorkspace.processEvents(); + + assertThat(columnNamesOfUsers(editor)) + .as("The window of the editor that is activated must make it read the changed DTD.") + .containsExactly("ID", "NAME"); + } + } + + /** + * Opens a dataset whose external DTD declares the column ID, then changes the DTD on disk so that it + * declares NAME too, without telling the editor. + */ + private static FlatXmlDatasetEditor openWithDtdThatThenGainsAColumn(final UiTestWorkspace workspace) + throws Exception + { + final IFile dtdFile = workspace.createFile("my.dtd", "\n" + + "\n\n"); + final IFile datasetFile = workspace.createFile("dataset.xml", + "\n\n \n\n"); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(datasetFile); + dtdFile.setContents( + new ByteArrayInputStream(("\n\n" + + "\n") + .getBytes(StandardCharsets.UTF_8)), + true, false, null); + return editor; + } + + private static List columnNamesOfUsers(final FlatXmlDatasetEditor editor) + { + final DatasetTable users = editor.getDatasetDocument().getModel().findTable("USERS").orElseThrow(); + return users.getColumns().stream().map(DatasetColumn::name).toList(); + } + + /** + * Returns a window that is not the one of any editor, for an editor to tell it from its own. + */ + private static IWorkbenchWindow anotherWindow() + { + return (IWorkbenchWindow) Proxy.newProxyInstance(IWorkbenchWindow.class.getClassLoader(), + new Class[] { IWorkbenchWindow.class }, (proxy, method, arguments) -> null); + } + + @Test + void testTablesPage_afterReloadingAChangedExternalDtd_updatesTheColumns() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile dtdFile = workspace.createFile("my.dtd", + "\n\n" + + "\n"); + final IFile datasetFile = workspace.createFile("dataset.xml", + "\n\n \n\n"); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(datasetFile); + final FlatXmlDatasetDocument datasetDocument = editor.getDatasetDocument(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("Before the DTD changes, only the originally declared column must appear.") + .containsExactly(new DatasetColumn("ID", true, true, false)); + + dtdFile.setContents( + new ByteArrayInputStream(("\n\n" + + "\n") + .getBytes(StandardCharsets.UTF_8)), + true, false, null); + datasetDocument.reloadDtd(); + UiTestWorkspace.processEvents(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getColumns()) + .as("Reloading a changed DTD must show the newly declared column.") + .containsExactly(new DatasetColumn("ID", true, true, false), + new DatasetColumn("NAME", true, false, false)); + } + } + + @Test + void testTablesPage_whenTheExternalDtdIsMissing_reportsDtdNotLoaded() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", "\n\n \n\n"); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + + final List problems = editor.getDatasetDocument().getModel().getProblems(); + + assertThat(problems).as("A missing external DTD must report DTD_NOT_LOADED.") + .anyMatch(problem -> problem.code() == ProblemCode.DTD_NOT_LOADED); + } + } + + private static CTabItem tabForTable(final TablesPage tablesPage, final String tableName) + { + for (final CTabItem item : tablesPage.getTabFolder().getItems()) + { + if (item.getText().equals(tableName)) + { + return item; + } + } + throw new AssertionError("No tab named " + tableName + "."); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/FlatXmlSourceEditorTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/FlatXmlSourceEditorTest.java new file mode 100644 index 00000000..cf8622a3 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/FlatXmlSourceEditorTest.java @@ -0,0 +1,487 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.dbunit.eclipse.dataset.core.edit.CellChange; +import org.dbunit.eclipse.dataset.ui.source.XmlDocumentSetupParticipant; +import org.dbunit.eclipse.dataset.ui.source.XmlTokenColors; +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IStorage; +import org.eclipse.core.runtime.IPath; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.resource.ColorRegistry; +import org.eclipse.jface.resource.ImageDescriptor; +import org.eclipse.jface.text.IDocumentExtension3; +import org.eclipse.nebula.widgets.nattable.NatTable; +import org.eclipse.nebula.widgets.nattable.edit.command.EditSelectionCommand; +import org.eclipse.swt.custom.StyleRange; +import org.eclipse.swt.custom.StyledText; +import org.eclipse.swt.graphics.RGB; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.ui.IEditorInput; +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.IPersistableElement; +import org.eclipse.ui.IStorageEditorInput; +import org.eclipse.ui.IWorkbenchPage; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.actions.ActionFactory; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link FlatXmlSourceEditor}, the Source page of a real dataset editor: its syntax coloring, the + * revert that the dataset editor offers on both pages, and how it closes the dataset editor. + */ +class FlatXmlSourceEditorTest +{ + private static final String CONTENT = """ + + + + + + """; + + private static final String SAVED_USERS = + ""; + + private static final String EDITED_USERS = + ""; + + @Test + void testOpen_whenFileIsAFlatXmlDataset_colorsEachXmlConstructWithItsThemeColor() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", CONTENT); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final StyledText sourceText = sourceText(editor); + final ColorRegistry colorRegistry = currentColorRegistry(); + + final Map colorIdsBySnippet = Map.of(" colors = new LinkedHashMap<>(); + final Map themeColors = new LinkedHashMap<>(); + colorIdsBySnippet.forEach((snippet, colorId) -> + { + colors.put(snippet, foregroundAt(sourceText, CONTENT.indexOf(snippet))); + themeColors.put(snippet, colorRegistry.getRGB(colorId)); + }); + + assertThat(colors).as("The Source page must color each XML construct with its theme color.") + .isEqualTo(themeColors); + } + } + + @Test + void testThemeColorChange_whileTheEditorIsOpen_recolorsTheSource() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", CONTENT); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final StyledText sourceText = sourceText(editor); + final ColorRegistry colorRegistry = currentColorRegistry(); + final RGB originalColor = colorRegistry.getRGB(XmlTokenColors.TAG_COLOR); + final RGB changedColor = new RGB(1, 2, 3); + try + { + colorRegistry.put(XmlTokenColors.TAG_COLOR, changedColor); + UiTestWorkspace.processEvents(); + + assertThat(foregroundAt(sourceText, CONTENT.indexOf("USERS"))) + .as("A theme color change must recolor the open Source page.") + .isEqualTo(changedColor); + } + finally + { + colorRegistry.put(XmlTokenColors.TAG_COLOR, originalColor); + } + } + } + + @Test + void testOpen_whenInputIsAReadOnlyStorageInput_installsXmlPartitioning() throws Exception + { + final IWorkbenchPage page = PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(); + final IEditorPart editor = + page.openEditor(new StorageEditorInput(CONTENT), FlatXmlDatasetEditor.ID); + try + { + UiTestWorkspace.processEvents(); + final FlatXmlSourceEditor sourceEditor = ((FlatXmlDatasetEditor) editor).getSourceEditor(); + final IDocumentExtension3 document = (IDocumentExtension3) sourceEditor.getDocumentProvider() + .getDocument(sourceEditor.getEditorInput()); + + assertThat(document.getDocumentPartitioner(XmlDocumentSetupParticipant.PARTITIONING)) + .as("A read-only storage input, such as a JAR entry or a history revision, must still " + + "get XML partitioning.") + .isNotNull(); + } + finally + { + page.closeEditor(editor, false); + UiTestWorkspace.processEvents(); + } + } + + @Test + void testRevertAction_afterAGridEditOnTheTablesPage_restoresTheSavedText() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", SAVED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final IAction revert = installedRevertAction(editor); + + assertThat(revert).as("The Tables page must have a Revert action.").isNotNull(); + assertThat(revert.isEnabled()).as("A document without changes has nothing to revert.").isFalse(); + + editor.getDatasetDocument().setCells("USERS", List.of(new CellChange(0, "NAME", "Carol"))); + UiTestWorkspace.processEvents(); + + assertThat(revert.isEnabled()).as("A change made in the grid must enable Revert.").isTrue(); + + revert.run(); + UiTestWorkspace.processEvents(); + + assertThat(documentText(editor)).as("Revert must restore the text of the last save.") + .isEqualTo(SAVED_USERS); + assertThat(editor.isDirty()).as("A reverted editor must not be dirty.").isFalse(); + assertThat(revert.isEnabled()).as("A reverted document has nothing left to revert.").isFalse(); + } + } + + @Test + void testRevertAction_whileTheTablesPageEditsACell_closesTheCellEditorWithoutWritingItsValue() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", SAVED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + editor.getDatasetDocument().setCells("USERS", List.of(new CellChange(0, "NAME", "Carol"))); + giveTablesPageASize(editor); + final TablesPage tablesPage = editor.getTablesPage(); + final NatTable natTable = (NatTable) tablesPage.getTabFolder().getSelection().getControl(); + tablesPage.selectRegion(1, 1, 1, 1); + natTable.doCommand(new EditSelectionCommand(natTable, natTable.getConfigRegistry())); + natTable.getActiveCellEditor().setEditorValue("Zed"); + + installedRevertAction(editor).run(); + UiTestWorkspace.processEvents(); + + assertThat(tablesPage.hasActiveCellEditor()) + .as("A revert must close the cell editor, or its value lands in the reverted document.") + .isFalse(); + natTable.commitAndCloseActiveCellEditor(); + UiTestWorkspace.processEvents(); + assertThat(documentText(editor)).as("The value of the closed cell editor must not be written.") + .isEqualTo(SAVED_USERS); + } + } + + @Test + void testClose_whenNotSaving_closesTheDatasetEditorThatHoldsTheSourcePage() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", SAVED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final IEditorInput input = editor.getEditorInput(); + + editor.getSourceEditor().close(false); + + assertThat(activePage().findEditor(input)) + .as("The editor must still be open when the call returns, as it is after the close of " + + "any text editor, because the code that asked for it goes on using the editor.") + .isNotNull(); + + UiTestWorkspace.processEvents(); + + assertThat(activePage().findEditor(input)) + .as("Closing the Source page must close the dataset editor that holds it, because the " + + "workbench page does not know the page of an editor by itself.") + .isNull(); + } + } + + @Test + void testClose_whenNotSavingAndTheEditorIsDirty_closesItAndLeavesTheFileAsSaved() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace(); + MessageDialogDriver dialogDriver = new MessageDialogDriver()) + { + final IFile file = workspace.createFile("dataset.xml", SAVED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final IEditorInput input = editor.getEditorInput(); + editor.getDatasetDocument().setCells("USERS", List.of(new CellChange(0, "NAME", "Carol"))); + UiTestWorkspace.processEvents(); + assertThat(editor.isDirty()).as("A grid edit must make the editor dirty.").isTrue(); + dialogDriver.expectNoDialog(); + + editor.getSourceEditor().close(false); + UiTestWorkspace.processEvents(); + + assertThat(dialogDriver.unexpectedDialogTitles()) + .as("Closing without saving must not ask whether to save.").isEmpty(); + assertThat(activePage().findEditor(input)).as("Closing without saving must close the editor.") + .isNull(); + assertThat(fileText(file)).as("Closing without saving must leave the file as it was saved.") + .isEqualTo(SAVED_USERS); + } + } + + @Test + void testClose_whenSavingAndTheEditorIsDirty_savesTheChangesAndCloses() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace(); + MessageDialogDriver dialogDriver = new MessageDialogDriver()) + { + final IFile file = workspace.createFile("dataset.xml", SAVED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final IEditorInput input = editor.getEditorInput(); + editor.getDatasetDocument().setCells("USERS", List.of(new CellChange(0, "NAME", "Carol"))); + UiTestWorkspace.processEvents(); + dialogDriver.pressButtonOfNextDialog(MessageDialogDriver.SAVE_BUTTON_OF_CLOSE_PROMPT); + + editor.getSourceEditor().close(true); + UiTestWorkspace.processEvents(); + + assertThat(dialogDriver.hasHandledDialog()) + .as("Closing a dirty editor with its changes to be saved must ask whether to save them.") + .isTrue(); + assertThat(dialogDriver.unexpectedDialogTitles()) + .as("The prompt to save must be the only dialog that opens.").isEmpty(); + assertThat(fileText(file)).as("Choosing Save must write the changes before the editor closes.") + .isEqualTo(EDITED_USERS); + assertThat(activePage().findEditor(input)).as("The editor must close once it is saved.") + .isNull(); + } + } + + @Test + void testClose_whenAskedAgainBeforeTheEditorCloses_closesTheEditorOnceWithoutAnError() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace(); + LogRecorder log = new LogRecorder(PlatformUI.class)) + { + final IFile file = workspace.createFile("dataset.xml", SAVED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final IEditorInput input = editor.getEditorInput(); + final FlatXmlSourceEditor sourceEditor = editor.getSourceEditor(); + + sourceEditor.close(false); + sourceEditor.close(false); + UiTestWorkspace.processEvents(); + + assertThat(activePage().findEditor(input)).as("The editor must close.").isNull(); + assertThat(log.statuses()).filteredOn(status -> status.matches(IStatus.ERROR)) + .extracting(IStatus::getMessage) + .as("A second request to close an editor that has already closed must be harmless, " + + "not an exception that the workbench reports.") + .isEmpty(); + } + } + + @Test + void testClose_whenTheFileIsDeletedBeforeTheEditorCloses_doesNotAskAboutTheFile() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace(); + MessageDialogDriver dialogDriver = new MessageDialogDriver()) + { + final IFile file = workspace.createFile("dataset.xml", SAVED_USERS); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final IEditorInput input = editor.getEditorInput(); + final FlatXmlSourceEditor sourceEditor = editor.getSourceEditor(); + sourceEditor.checkExternalModification(); + editor.getDatasetDocument().setCells("USERS", List.of(new CellChange(0, "NAME", "Carol"))); + UiTestWorkspace.processEvents(); + dialogDriver.expectNoDialog(); + + sourceEditor.close(false); + Files.delete(file.getLocation().toFile().toPath()); + sourceEditor.checkExternalModification(); + UiTestWorkspace.processEvents(); + + assertThat(dialogDriver.unexpectedDialogTitles()) + .as("An editor that is about to close must not ask what to do about its file.").isEmpty(); + assertThat(activePage().findEditor(input)).as("The editor must still close.").isNull(); + } + } + + /** + * Gives the grid a size, which an editor in the test workbench lacks while the intro hides its shell, + * so that NatTable can place the editor of a cell. + */ + private static void giveTablesPageASize(final FlatXmlDatasetEditor editor) + { + final Composite page = (Composite) editor.getTablesPage().getControl(); + page.setSize(800, 600); + page.layout(true, true); + UiTestWorkspace.processEvents(); + } + + private static IAction installedRevertAction(final FlatXmlDatasetEditor editor) + { + return editor.getEditorSite().getActionBars().getGlobalActionHandler(ActionFactory.REVERT.getId()); + } + + private static String documentText(final FlatXmlDatasetEditor editor) + { + final FlatXmlSourceEditor sourceEditor = editor.getSourceEditor(); + return sourceEditor.getDocumentProvider().getDocument(sourceEditor.getEditorInput()).get(); + } + + private static String fileText(final IFile file) throws Exception + { + try (InputStream contents = file.getContents()) + { + return new String(contents.readAllBytes(), StandardCharsets.UTF_8); + } + } + + private static IWorkbenchPage activePage() + { + return PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(); + } + + private static StyledText sourceText(final FlatXmlDatasetEditor editor) + { + return (StyledText) editor.getSourceEditor().getAdapter(Control.class); + } + + private static ColorRegistry currentColorRegistry() + { + return PlatformUI.getWorkbench().getThemeManager().getCurrentTheme().getColorRegistry(); + } + + private static RGB foregroundAt(final StyledText sourceText, final int offset) + { + final StyleRange styleRange = sourceText.getStyleRangeAtOffset(offset); + if (styleRange == null || styleRange.foreground == null) + { + return null; + } + return styleRange.foreground.getRGB(); + } + + /** + * A read-only, non-file input, as for a dataset stored inside a JAR or held by a history revision: + * neither is backed by a workspace {@link IFile} or a file store, so the file buffer manager never + * connects to it. + */ + private static final class StorageEditorInput implements IStorageEditorInput + { + private final String content; + + StorageEditorInput(final String content) + { + this.content = content; + } + + @Override + public IStorage getStorage() + { + return new IStorage() + { + @Override + public InputStream getContents() + { + return new ByteArrayInputStream(content.getBytes(StandardCharsets.UTF_8)); + } + + @Override + public IPath getFullPath() + { + return null; + } + + @Override + public String getName() + { + return "dataset.xml"; + } + + @Override + public boolean isReadOnly() + { + return true; + } + + @Override + public T getAdapter(final Class adapter) + { + return null; + } + }; + } + + @Override + public boolean exists() + { + return true; + } + + @Override + public ImageDescriptor getImageDescriptor() + { + return null; + } + + @Override + public String getName() + { + return "dataset.xml"; + } + + @Override + public IPersistableElement getPersistable() + { + return null; + } + + @Override + public String getToolTipText() + { + return "dataset.xml"; + } + + @Override + public T getAdapter(final Class adapter) + { + return null; + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/LogRecorder.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/LogRecorder.java new file mode 100644 index 00000000..4e32c5bf --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/LogRecorder.java @@ -0,0 +1,62 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.core.runtime.ILog; +import org.eclipse.core.runtime.ILogListener; +import org.eclipse.core.runtime.IStatus; + +/** + * Records what code of the dataset editor bundle logs while a test runs, and stops recording when closed. + */ +final class LogRecorder implements AutoCloseable +{ + private final ILog log; + + private final List statuses = new ArrayList<>(); + + private final ILogListener listener = (status, plugin) -> statuses.add(status); + + /** + * Starts recording the log of the bundle that a class belongs to. + * + * @param owner A class of the bundle whose log to record. + */ + LogRecorder(final Class owner) + { + log = ILog.of(owner); + log.addLogListener(listener); + } + + List statuses() + { + return List.copyOf(statuses); + } + + @Override + public void close() + { + log.removeLogListener(listener); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/MessageDialogDriver.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/MessageDialogDriver.java new file mode 100644 index 00000000..9ba0ae1a --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/MessageDialogDriver.java @@ -0,0 +1,247 @@ +/* + * + * 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.eclipse.dataset.ui.editor; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; + +import org.eclipse.jface.dialogs.Dialog; +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Event; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.ui.PlatformUI; + +/** + * Presses a button of the next dialog that the code under test opens on the display of the workbench, + * whichever shell it opens on, and closes every other dialog that opens. A test cannot do it itself, because + * the call that opens a modal dialog does not return until the dialog is closed. The buttons are identified + * by their position, from 0, which JFace gives them as their identifier, so a test does not depend on the + * language of their labels. + *

+ * A dialog that the test did not expect is closed at once, the way the close box of its window closes it, + * which cancels it, so that the test fails on it instead of blocking the build in the dialog's event loop. + * Closing it any other way would answer it, for example by choosing to save, and the answer could open + * another dialog. The driver watches until it is closed, so close it when the test ends. + */ +final class MessageDialogDriver implements AutoCloseable +{ + /** + * The position of Save in the prompt that a closing editor with unsaved changes shows. + */ + static final int SAVE_BUTTON_OF_CLOSE_PROMPT = 0; + + /** + * The position of Close in the dialog that an editor shows when its file was deleted. + */ + static final int CLOSE_BUTTON_OF_DELETED_FILE_DIALOG = 1; + + /** + * The position of Replace in the dialog that an editor shows when its file changed outside the workbench. + */ + static final int REPLACE_BUTTON_OF_CHANGED_FILE_DIALOG = 0; + + /** + * The position of Ignore in the dialog that an editor shows when its file changed outside the workbench. + */ + static final int IGNORE_BUTTON_OF_CHANGED_FILE_DIALOG = 1; + + /** + * The position of Change in the dialog that a cell editor shows when its value fails validation, which + * keeps the cell editor open. + */ + static final int CHANGE_BUTTON_OF_VALIDATION_DIALOG = 0; + + /** + * The position of Discard in the dialog that a cell editor shows when its value fails validation, which + * closes the cell editor without writing the value. + */ + static final int DISCARD_BUTTON_OF_VALIDATION_DIALOG = 1; + + private static final int POLL_MILLIS = 20; + + private final Display display; + + private final Runnable poller = this::poll; + + private final List unexpectedDialogTitles = new ArrayList<>(); + + private final Set closedDialogs = new HashSet<>(); + + private final Set pressedDialogs = new HashSet<>(); + + private Predicate expectedDialog = dialog -> false; + + private boolean watching; + + private boolean handled; + + private boolean everyDialogExpected; + + MessageDialogDriver() + { + display = PlatformUI.getWorkbench().getDisplay(); + } + + /** + * Presses a button of the next dialog that opens. Any dialog after that one is not expected. + * + * @param buttonIndex The position of the button among the dialog's buttons, from 0. + */ + void pressButtonOfNextDialog(final int buttonIndex) + { + watch(dialog -> pressButton(dialog, buttonIndex)); + } + + /** + * Presses a button of every dialog that opens, for code that shows the same dialog more than once. + * + * @param buttonIndex The position of the button among the dialog's buttons, from 0. + */ + void pressButtonOfEveryDialog(final int buttonIndex) + { + watch(dialog -> pressButton(dialog, buttonIndex)); + everyDialogExpected = true; + } + + /** + * Expects that no dialog opens, so that every dialog that does is closed and reported. + */ + void expectNoDialog() + { + watch(dialog -> false); + } + + /** + * Tells whether the expected dialog opened and its button was pressed. + * + * @return True once the button was pressed. + */ + boolean hasHandledDialog() + { + return handled; + } + + /** + * Lists the dialogs that opened without being expected, and were closed. + * + * @return The titles of the dialogs, in the order they opened. + */ + List unexpectedDialogTitles() + { + return List.copyOf(unexpectedDialogTitles); + } + + /** + * Stops watching, so that the driver cannot touch a dialog of a later test. + */ + @Override + public void close() + { + watching = false; + display.timerExec(-1, poller); + } + + private void watch(final Predicate expected) + { + expectedDialog = expected; + handled = false; + everyDialogExpected = false; + unexpectedDialogTitles.clear(); + closedDialogs.clear(); + pressedDialogs.clear(); + watching = true; + display.asyncExec(poller); + } + + private void poll() + { + if (!watching) + { + return; + } + final Shell[] shells = display.getShells(); + for (final Shell shell : shells) + { + if (shell.isVisible() && shell.getData() instanceof Dialog) + { + handle(shell); + } + } + display.timerExec(POLL_MILLIS, poller); + } + + private void handle(final Shell dialog) + { + final boolean mayBeExpected = everyDialogExpected ? !pressedDialogs.contains(dialog) : !handled; + if (mayBeExpected && expectedDialog.test(dialog)) + { + handled = true; + pressedDialogs.add(dialog); + return; + } + if (closedDialogs.add(dialog)) + { + unexpectedDialogTitles.add(dialog.getText()); + dialog.close(); + } + } + + private static boolean pressButton(final Shell dialog, final int buttonIndex) + { + final Optional

` with no other attribute for that format +and does not open it in this editor by default; choose *Open With* to open it anyway. +* The grid never sorts or filters rows, because their order matters to dbUnit. +* Columns and tables cannot be reordered in the grid; reorder them on the Source page. +* The Tables page has no find or replace across cells; use the Source page's Find/Replace instead. +* Problems appear in the editor only, not in the Problems view. +* The editor does not connect to databases. +* dbUnit gives the DTD's default values only to elements spelled exactly like the DTD's element, +and a default replaces a value that a row writes with the attribute spelled in another letter case. +The editor shows the defaults whatever the spelling. +Use the DTD's spelling, as the warnings about differently spelled tables and columns suggest. +* Placeholders of dbUnit's `ReplacementDataSet`, such as `[NULL]`, are ordinary text to the editor. +* Every change on the Tables page reads the whole file again, so changes take longer in large datasets. +On a typical developer computer, a change takes a fraction of a second in 100,000 rows of 4 columns (9 MB), +and about a second in 100,000 rows of 20 columns (42 MB), which also takes a few seconds and about 400 MB of memory +to open. diff --git a/src/site/resources/images/source-page.png b/src/site/resources/images/source-page.png new file mode 100644 index 00000000..7e512bdb Binary files /dev/null and b/src/site/resources/images/source-page.png differ diff --git a/src/site/resources/images/tables-page.png b/src/site/resources/images/tables-page.png new file mode 100644 index 00000000..78282bc8 Binary files /dev/null and b/src/site/resources/images/tables-page.png differ diff --git a/src/site/site.xml b/src/site/site.xml index 6b4ffbc4..6e85e310 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -40,6 +40,7 @@ +