From 1698896ab515ebf50787c4bfebc7efd32447c326 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Thu, 24 Sep 2026 06:11:27 -0500 Subject: [PATCH 001/170] feat(model): Add format-neutral dataset model * Add DatasetModel, DatasetTable, DatasetColumn, DatasetRow, CellAddress, and DatasetProblem as immutable snapshots of a parsed dataset. * Add ProblemSeverity and ProblemCode, with blocksEditing() identifying the four codes that block editing. * Add the core Messages NLS class and messages.properties for externalized strings. * Export org.dbunit.eclipse.dataset.core.model and import org.eclipse.osgi.util for NLS in the core bundle manifest. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr --- .../META-INF/MANIFEST.MF | 4 +- .../dbunit/eclipse/dataset/core/Messages.java | 42 ++++++ .../eclipse/dataset/core/messages.properties | 0 .../dataset/core/model/CellAddress.java | 33 +++++ .../dataset/core/model/DatasetColumn.java | 35 +++++ .../dataset/core/model/DatasetModel.java | 129 +++++++++++++++++ .../dataset/core/model/DatasetProblem.java | 39 +++++ .../dataset/core/model/DatasetRow.java | 77 ++++++++++ .../dataset/core/model/DatasetTable.java | 135 ++++++++++++++++++ .../dataset/core/model/ProblemCode.java | 131 +++++++++++++++++ .../dataset/core/model/ProblemSeverity.java | 44 ++++++ .../dataset/core/model/DatasetModelTest.java | 89 ++++++++++++ .../dataset/core/model/DatasetRowTest.java | 78 ++++++++++ .../dataset/core/model/DatasetTableTest.java | 100 +++++++++++++ .../dataset/core/model/ProblemCodeTest.java | 53 +++++++ src/changes/changes.xml | 1 + 16 files changed, 989 insertions(+), 1 deletion(-) create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/Messages.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/messages.properties create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/CellAddress.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetColumn.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetModel.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetProblem.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetRow.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetTable.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/ProblemCode.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/ProblemSeverity.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/model/DatasetModelTest.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/model/DatasetRowTest.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/model/DatasetTableTest.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/model/ProblemCodeTest.java 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..61fad6f6 100644 --- a/bundles/org.dbunit.eclipse.dataset.core/META-INF/MANIFEST.MF +++ b/bundles/org.dbunit.eclipse.dataset.core/META-INF/MANIFEST.MF @@ -11,4 +11,6 @@ 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 +Import-Package: org.eclipse.osgi.util;version="1.1.0" +Export-Package: org.dbunit.eclipse.dataset.core, + org.dbunit.eclipse.dataset.core.model 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..b9070520 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/Messages.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.core; + +import org.eclipse.osgi.util.NLS; + +/** + * Externalized, user-visible strings of the dataset core bundle. + * + * @since 1.0.0 + */ +public final class Messages extends NLS +{ + private static final String BUNDLE_NAME = "org.dbunit.eclipse.dataset.core.messages"; + + 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/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..e69de29b 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..3364269f --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetColumn.java @@ -0,0 +1,35 @@ +/* + * + * 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; + +/** + * 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. + * @since 1.0.0 + */ +public record DatasetColumn(String name, boolean declared, boolean hasValues, boolean pending) +{ +} 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..65cef264 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetModel.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.core.model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +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 boolean editable; + + /** + * 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. + */ + public DatasetModel(final List tables, final List problems, + final boolean editable) + { + this.tables = List.copyOf(tables); + this.problems = List.copyOf(problems); + this.editable = editable; + } + + /** + * 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. + * + * @param tableKey The table key to filter by. + * @return An unmodifiable list of the problems whose table key equals the given key. + */ + public List getProblems(final String tableKey) + { + final List filtered = new ArrayList<>(); + for (final DatasetProblem problem : problems) + { + if (Objects.equals(problem.tableKey(), tableKey)) + { + filtered.add(problem); + } + } + return Collections.unmodifiableList(filtered); + } + + /** + * 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; + } +} 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..0adac6d0 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/DatasetTable.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.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; + + /** + * 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. + */ + public DatasetTable(final String key, final String name, final List columns, + final List rows, final boolean declaredOnly) + { + this.key = key; + this.name = name; + this.columns = List.copyOf(columns); + this.rows = List.copyOf(rows); + this.declaredOnly = declaredOnly; + } + + /** + * Returns the identity of the table across refreshes. + * + * @return The upper-cased name (Locale.ENGLISH), or the exact name when table names are + * case-sensitive. + */ + 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 its name and this name are equal + * after upper-casing them with Locale.ENGLISH, which is how the rest of the plugin keys + * columns. + * @return The index of the first matching column, or -1 when no column matches. + */ + public int getColumnIndex(final String columnName) + { + final String columnKey = columnName.toUpperCase(Locale.ENGLISH); + for (int index = 0; index < columns.size(); index++) + { + final DatasetColumn column = columns.get(index); + if (column.name().toUpperCase(Locale.ENGLISH).equals(columnKey)) + { + return index; + } + } + return -1; + } + + /** + * 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; + } +} 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..16e96bae --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/model/ProblemCode.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.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 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..872a7fa7 --- /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, + + /** + * dbUnit tolerates the condition, 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/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..c06666a7 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/model/DatasetModelTest.java @@ -0,0 +1,89 @@ +/* + * + * 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); + + @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 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..df30c5aa --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/model/DatasetTableTest.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 static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * Tests case-insensitive column lookup and the plain getters 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 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(); + } +} 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/src/changes/changes.xml b/src/changes/changes.xml index 0637294d..26615f9a 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -8,6 +8,7 @@ + Add the dbUnit Dataset Editor, a spreadsheet-like editor for dbUnit flat XML dataset files. From 0002ae74fd818bbec68dbfa672bb7504ccf15a13 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Thu, 24 Sep 2026 06:22:10 -0500 Subject: [PATCH 002/170] feat(flatxml): Add XML name rules and attribute value codec * Add XmlNames for the XML 1.0 Name production, used for table and column names and later by the flat XML parser. * Add AttributeValueCodec to decode attribute values (predefined entities, character references, and whitespace normalization) and to escape values for writing, including charset-aware escaping of characters the document's encoding cannot represent. * Add AttributeValueException, carrying the offset of a decoding error and whether it is an unsupported entity. * Add DatasetEditException, because AttributeValueCodec.escape() must reject a value containing a character outside the XML 1.0 Char range; the rest of the core.edit package follows in a later commit. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr --- .../core/edit/DatasetEditException.java | 41 +++ .../core/flatxml/AttributeValueCodec.java | 262 +++++++++++++++++ .../core/flatxml/AttributeValueException.java | 72 +++++ .../dataset/core/flatxml/XmlNames.java | 109 +++++++ .../core/flatxml/AttributeValueCodecTest.java | 273 ++++++++++++++++++ .../dataset/core/flatxml/XmlNamesTest.java | 80 +++++ 6 files changed, 837 insertions(+) create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/DatasetEditException.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/AttributeValueCodec.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/AttributeValueException.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/XmlNames.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/AttributeValueCodecTest.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/XmlNamesTest.java diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/DatasetEditException.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/DatasetEditException.java new file mode 100644 index 00000000..2f9d32d1 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/DatasetEditException.java @@ -0,0 +1,41 @@ +/* + * + * 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; + +/** + * Thrown when an edit is rejected. + * + * @since 1.0.0 + */ +public class DatasetEditException extends RuntimeException +{ + private static final long serialVersionUID = 1L; + + /** + * Creates a dataset edit exception. + * + * @param message The complete, user-facing sentence describing why the edit was rejected. + */ + public DatasetEditException(final String message) + { + super(message); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/AttributeValueCodec.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/AttributeValueCodec.java new file mode 100644 index 00000000..ffbbc4b9 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/AttributeValueCodec.java @@ -0,0 +1,262 @@ +/* + * + * 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.CharsetEncoder; +import java.util.Locale; +import java.util.Map; + +import org.dbunit.eclipse.dataset.core.edit.DatasetEditException; + +/** + * Decodes and escapes dbUnit flat XML attribute values. + * + * @since 1.0.0 + */ +public final class AttributeValueCodec +{ + private static final Map ESCAPES = Map.of((int) '&', "&", (int) '<', "<", + (int) '>', ">", (int) '"', """, (int) '\'', "'", (int) '\t', " ", (int) '\n', + " ", (int) '\r', " "); + + private static final Map PREDEFINED_ENTITIES = + Map.of("lt", '<', "gt", '>', "amp", '&', "quot", '"', "apos", '\''); + + private AttributeValueCodec() + { + } + + /** + * Returns whether a code point is an XML 1.0 {@code Char}. + * + * @param codePoint The Unicode code point to test. + * @return True when the code point may appear in an XML 1.0 document. + */ + public static boolean isXmlChar(final int codePoint) + { + return codePoint == 0x9 + || codePoint == 0xA + || codePoint == 0xD + || (codePoint >= 0x20 && codePoint <= 0xD7FF) + || (codePoint >= 0xE000 && codePoint <= 0xFFFD) + || (codePoint >= 0x10000 && codePoint <= 0x10FFFF); + } + + /** + * Returns the index after the tab, line feed, or carriage return at an index, which is one further for a + * carriage return followed by a line feed, because that pair is one line end. + */ + private static int indexAfterWhitespace(final CharSequence raw, final int index) + { + final boolean isCrLf = + raw.charAt(index) == '\r' && index + 1 < raw.length() && raw.charAt(index + 1) == '\n'; + return isCrLf ? index + 2 : index + 1; + } + + /** + * Decodes a raw attribute value: the five predefined entities and character references become their + * characters, and a literal tab, line feed, or carriage return normalizes to a space. + * + * @param raw The raw text between the attribute's quotes, exactly as written in the document. + * @return The decoded value. + * @throws AttributeValueException When an entity or character reference is invalid. + */ + public static String decode(final CharSequence raw) throws AttributeValueException + { + final StringBuilder result = new StringBuilder(raw.length()); + int index = 0; + while (index < raw.length()) + { + final char current = raw.charAt(index); + if (current == '&') + { + index = decodeReference(raw, index, result); + } + else if (current == '\t' || current == '\n' || current == '\r') + { + result.append(' '); + index = indexAfterWhitespace(raw, index); + } + else + { + result.append(current); + index++; + } + } + return result.toString(); + } + + /** + * Escapes a value for writing as a double- or single-quoted XML attribute value. + * + * @param value The value to escape. + * @param encoder The encoder of the document's charset, or null when every character is encodable. + * @return The escaped text to write between the attribute's quotes. + * @throws DatasetEditException When the value contains a code point that is not an XML 1.0 + * {@code Char}. + */ + public static String escape(final String value, final CharsetEncoder encoder) + { + final StringBuilder result = new StringBuilder(value.length()); + int index = 0; + while (index < value.length()) + { + final int codePoint = value.codePointAt(index); + appendEscaped(result, codePoint, encoder); + index += Character.charCount(codePoint); + } + return result.toString(); + } + + private static void appendEscaped(final StringBuilder result, final int codePoint, + final CharsetEncoder encoder) + { + if (!isXmlChar(codePoint)) + { + throw new DatasetEditException( + "The character U+" + Integer.toHexString(codePoint).toUpperCase(Locale.ROOT) + + " is not allowed in an XML 1.0 document."); + } + final String escape = ESCAPES.get(codePoint); + if (escape == null) + { + appendLiteralOrCharacterReference(result, codePoint, encoder); + } + else + { + result.append(escape); + } + } + + private static void appendLiteralOrCharacterReference(final StringBuilder result, final int codePoint, + final CharsetEncoder encoder) + { + final boolean encodable = + encoder == null || encoder.canEncode(new String(Character.toChars(codePoint))); + if (encodable) + { + result.appendCodePoint(codePoint); + } + else + { + result.append("&#x").append(Integer.toHexString(codePoint).toUpperCase(Locale.ROOT)) + .append(';'); + } + } + + private static int decodeReference(final CharSequence raw, final int ampersandOffset, + final StringBuilder result) throws AttributeValueException + { + final int semicolon = indexOf(raw, ';', ampersandOffset + 1); + if (semicolon < 0) + { + throw new AttributeValueException("'&' must start a valid entity or character reference.", + ampersandOffset, false); + } + final String body = raw.subSequence(ampersandOffset + 1, semicolon).toString(); + final Character predefined = PREDEFINED_ENTITIES.get(body); + if (predefined == null) + { + decodeCharacterOrThrow(body, ampersandOffset, result); + } + else + { + result.append(predefined.charValue()); + } + return semicolon + 1; + } + + private static void decodeCharacterOrThrow(final String body, final int ampersandOffset, + final StringBuilder result) throws AttributeValueException + { + if (body.startsWith("#x")) + { + result.appendCodePoint(parseCharacterReference(body.substring(2), 16, ampersandOffset)); + } + else if (body.startsWith("#")) + { + result.appendCodePoint(parseCharacterReference(body.substring(1), 10, ampersandOffset)); + } + else + { + throw new AttributeValueException( + "Entity reference '&" + body + ";' is not supported; only the predefined entities " + + "and character references are available without a DTD.", + ampersandOffset, true); + } + } + + private static int parseCharacterReference(final String digits, final int radix, + final int referenceOffset) throws AttributeValueException + { + if (digits.isEmpty()) + { + throw new AttributeValueException("A character reference must have at least one digit.", + referenceOffset, false); + } + long value = 0; + for (int index = 0; index < digits.length(); index++) + { + final int digit = asciiDigit(digits.charAt(index), radix); + if (digit < 0) + { + throw new AttributeValueException( + "'" + digits + "' is not a valid character reference.", referenceOffset, false); + } + value = value * radix + digit; + if (value > Character.MAX_CODE_POINT) + { + throw new AttributeValueException("A character reference must not exceed U+10FFFF.", + referenceOffset, false); + } + } + if (!isXmlChar((int) value)) + { + throw new AttributeValueException( + "Character reference U+" + Long.toHexString(value).toUpperCase(Locale.ROOT) + + " is not allowed in XML 1.0.", + referenceOffset, false); + } + return (int) value; + } + + /** + * Returns the value of an ASCII digit, or -1 for any other character. Character.digit alone also + * accepts the digits of other scripts and fullwidth letters, which XML does not allow in a character + * reference. + */ + private static int asciiDigit(final char character, final int radix) + { + return character < 0x80 ? Character.digit(character, radix) : -1; + } + + private static int indexOf(final CharSequence text, final char target, final int fromIndex) + { + for (int index = fromIndex; index < text.length(); index++) + { + if (text.charAt(index) == target) + { + return index; + } + } + return -1; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/AttributeValueException.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/AttributeValueException.java new file mode 100644 index 00000000..48f4f8af --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/AttributeValueException.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.core.flatxml; + +/** + * Thrown when {@link AttributeValueCodec#decode(CharSequence)} finds an invalid entity or character + * reference. + * + * @since 1.0.0 + */ +public class AttributeValueException extends Exception +{ + private static final long serialVersionUID = 1L; + + private final int offset; + + private final boolean unsupportedEntity; + + /** + * Creates an attribute value exception. + * + * @param message The complete sentence describing the problem. + * @param offset The offset of the problem, relative to the start of the raw value. + * @param unsupportedEntity True when the problem is an entity reference other than one of the five + * predefined entities or a character reference. + */ + public AttributeValueException(final String message, final int offset, final boolean unsupportedEntity) + { + super(message); + this.offset = offset; + this.unsupportedEntity = unsupportedEntity; + } + + /** + * Returns the offset of the problem. + * + * @return The offset, relative to the start of the raw value. + */ + public int getOffset() + { + return offset; + } + + /** + * Returns whether the problem is an unsupported entity reference. + * + * @return True when the problem is an entity reference other than one of the five predefined + * entities or a character reference; false for any other decoding error. + */ + public boolean isUnsupportedEntity() + { + return unsupportedEntity; + } +} 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..17d5d1e6 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/XmlNames.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.core.flatxml; + +/** + * The XML 1.0 {@code Name} production, used for table and column names and by the flat XML parser. + * + * @since 1.0.0 + */ +public final class XmlNames +{ + /** + * The code point ranges of the XML 1.0 {@code NameStartChar} production, each as its first and last code + * point. The ranges are in ascending order, so that a scan can stop at the first range that starts + * above the code point. + */ + private static final int[][] NAME_START_RANGES = { { ':', ':' }, { 'A', 'Z' }, { '_', '_' }, { 'a', 'z' }, + { 0xC0, 0xD6 }, { 0xD8, 0xF6 }, { 0xF8, 0x2FF }, { 0x370, 0x37D }, { 0x37F, 0x1FFF }, + { 0x200C, 0x200D }, { 0x2070, 0x218F }, { 0x2C00, 0x2FEF }, { 0x3001, 0xD7FF }, { 0xF900, 0xFDCF }, + { 0xFDF0, 0xFFFD }, { 0x10000, 0xEFFFF } }; + + 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 code point is a {@code NameStartChar}. + */ + public static boolean isNameStartChar(final int codePoint) + { + boolean inRange = false; + int index = 0; + while (index < NAME_START_RANGES.length && codePoint >= NAME_START_RANGES[index][0] && !inRange) + { + inRange = codePoint <= NAME_START_RANGES[index][1]; + index++; + } + return inRange; + } + + /** + * 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 code point is a {@code NameChar}. + */ + public static boolean isNameChar(final int codePoint) + { + return isNameStartChar(codePoint) + || codePoint == '-' + || codePoint == '.' + || (codePoint >= '0' && codePoint <= '9') + || codePoint == 0xB7 + || (codePoint >= 0x300 && codePoint <= 0x36F) + || (codePoint >= 0x203F && codePoint <= 0x2040); + } + + /** + * 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; + } +} 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..8e03df7e --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/AttributeValueCodecTest.java @@ -0,0 +1,273 @@ +/* + * + * 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.catchThrowableOfType; + +import java.nio.charset.CharsetEncoder; +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_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(); + } + + @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 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 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_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; + } + + 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/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..bcdec3b7 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/XmlNamesTest.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.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Tests the XML 1.0 Name production that dbUnit flat XML table and column names must follow. + */ +class XmlNamesTest +{ + @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 Date: Thu, 24 Sep 2026 06:45:41 -0500 Subject: [PATCH 003/170] feat(flatxml): Add offset-preserving flat XML parser * Add FlatXmlParser, a hand-written scanner that parses the flat XML dataset format in one left-to-right pass, recording exact offsets for the root, every element, and every attribute, so edits can rewrite the document in place instead of re-serializing it. * Add the parse result records FlatXmlDoctype, FlatXmlRoot, FlatXmlElement, FlatXmlAttribute, and FlatXmlParseResult; keep them package-private, since every consumer lives in this package. * Add the TestDatasets test helper and the core fixtures: the dbUnit flat XML fixtures copied byte-identical from the dbunit-extension repository (only their DOCTYPE's relative DTD path is adjusted), plus new fixtures for column sensing, an empty first element, a DOCTYPE internal subset, entities and other special characters, an ISO-8859-1 encoded document, and one file per parser error case. * Cover the scanner with FlatXmlParserTest: element and attribute offsets for both quote styles and multi-line start tags, comments and processing instructions in the prolog, body, and epilog, DOCTYPE with SYSTEM, PUBLIC, and an internal subset, a leading BOM, CR LF line ends, every blocking error with its offset, ignored body text, every fixture parsing without a blocking problem, and a counting guard on a generated 2,000- and 4,000-row dataset that keeps charAt calls linear in the text length. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr --- .../core/flatxml/FlatXmlAttribute.java | 48 ++ .../dataset/core/flatxml/FlatXmlDoctype.java | 38 + .../dataset/core/flatxml/FlatXmlElement.java | 44 ++ .../core/flatxml/FlatXmlParseResult.java | 40 + .../dataset/core/flatxml/FlatXmlParser.java | 737 ++++++++++++++++++ .../dataset/core/flatxml/FlatXmlRoot.java | 34 + .../eclipse/dataset/core/TestDatasets.java | 74 ++ .../core/flatxml/FlatXmlParserTest.java | 441 +++++++++++ .../resources/datasets/column-sensing.xml | 5 + .../flatXmlDataSetDtdDifferentCaseTest.dtd | 15 + .../datasets/dtd/flatXmlTableTest.dtd | 36 + .../test/resources/datasets/editor-sample.xml | 16 + .../datasets/first-element-empty.xml | 5 + .../flatXmlDataSetDtdDifferentCaseTest.xml | 19 + ...latXmlDataSetDuplicateMultipleCaseTest.xml | 8 + .../datasets/flatXmlDataSetDuplicateTest.xml | 8 + .../resources/datasets/flatXmlDataSetTest.xml | 21 + .../resources/datasets/flatXmlTableTest.xml | 16 + .../resources/datasets/internal-subset.xml | 17 + .../test/resources/datasets/iso-8859-1.xml | 4 + .../datasets/malformed/content-after-root.xml | 5 + .../malformed/duplicate-attribute.xml | 4 + .../datasets/malformed/less-than-in-value.xml | 4 + .../datasets/malformed/mismatched-end-tag.xml | 4 + .../missing-whitespace-between-attributes.xml | 4 + .../datasets/malformed/nested-element.xml | 4 + .../datasets/malformed/root-not-dataset.xml | 3 + .../datasets/malformed/unknown-entity.xml | 4 + .../malformed/unterminated-comment.xml | 5 + .../resources/datasets/special-characters.xml | 5 + 30 files changed, 1668 insertions(+) create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlAttribute.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDoctype.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlElement.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlParseResult.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlParser.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlRoot.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/TestDatasets.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlParserTest.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/column-sensing.xml create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd/flatXmlDataSetDtdDifferentCaseTest.dtd create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/dtd/flatXmlTableTest.dtd create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/editor-sample.xml create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/first-element-empty.xml create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/flatXmlDataSetDtdDifferentCaseTest.xml create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/flatXmlDataSetDuplicateMultipleCaseTest.xml create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/flatXmlDataSetDuplicateTest.xml create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/flatXmlDataSetTest.xml create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/flatXmlTableTest.xml create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/internal-subset.xml create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/iso-8859-1.xml create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/content-after-root.xml create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/duplicate-attribute.xml create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/less-than-in-value.xml create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/mismatched-end-tag.xml create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/missing-whitespace-between-attributes.xml create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/nested-element.xml create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/root-not-dataset.xml create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/unknown-entity.xml create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/malformed/unterminated-comment.xml create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/resources/datasets/special-characters.xml diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlAttribute.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlAttribute.java new file mode 100644 index 00000000..6a3cfec6 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlAttribute.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.core.flatxml; + +/** + * One attribute of a flat XML element, with the offsets needed to rewrite it in place. + * + * @param name The attribute name, exactly as spelled in the document. + * @param value The decoded, normalized value. + * @param segmentOffset The start of the whitespace that precedes the name; for the first attribute this + * equals the element's nameEndOffset, so the segments of all of an element's + * attributes together cover exactly text[nameEndOffset, attributesEndOffset). + * @param nameOffset The offset of the first character of the name. + * @param valueOffset The offset of the first character after the opening quote. + * @param valueEndOffset The offset of the closing quote. + * @param quote The quote character used, either a double or a single quote. + */ +record FlatXmlAttribute(String name, String value, int segmentOffset, int nameOffset, int valueOffset, + int valueEndOffset, char quote) +{ + /** + * Returns the offset just after this attribute, including its closing quote. + * + * @return {@code valueEndOffset() + 1}. + */ + int endOffset() + { + return valueEndOffset + 1; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDoctype.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDoctype.java new file mode 100644 index 00000000..43352931 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDoctype.java @@ -0,0 +1,38 @@ +/* + * + * 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; + +/** + * A parsed {@code } declaration. + * + * @param rootName The document type name. + * @param publicId The public identifier, or null when none was given. + * @param systemId The system identifier, or null when none was given. + * @param internalSubset The raw text of the internal subset, or null when there is none. + * @param internalSubsetOffset The offset of the internal subset's first character, right after its + * opening {@code [}; meaningless when internalSubset is null. + * @param offset The offset of the declaration's opening angle bracket. + * @param endOffset The offset just after the declaration's closing {@code >}. + */ +record FlatXmlDoctype(String rootName, String publicId, String systemId, String internalSubset, + int internalSubsetOffset, int offset, int endOffset) +{ +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlElement.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlElement.java new file mode 100644 index 00000000..dfbedf76 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlElement.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.flatxml; + +import java.util.List; + +/** + * One element parsed from the flat XML body: a table row when it has attributes, or a marker when it + * does not. + * + * @param name The element name, exactly as spelled in the document. + * @param offset The offset of the start tag's opening angle bracket. + * @param nameEndOffset The offset just after the element name. + * @param attributes The element's attributes, in document order. + * @param attributesEndOffset The offset just after the last attribute, or equal to nameEndOffset when + * there are no attributes. + * @param startTagEndOffset The offset just after the start tag's closing {@code >} or {@code />}. + * @param selfClosing True when the element was written as a self-closing tag. + * @param endTagOffset The offset of the end tag's opening angle bracket, or -1 when selfClosing is true. + * @param endOffset The offset just after the whole element. + */ +record FlatXmlElement(String name, int offset, int nameEndOffset, List attributes, + int attributesEndOffset, int startTagEndOffset, boolean selfClosing, int endTagOffset, + int endOffset) +{ +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlParseResult.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlParseResult.java new file mode 100644 index 00000000..a3561f27 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlParseResult.java @@ -0,0 +1,40 @@ +/* + * + * 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.util.List; + +import org.dbunit.eclipse.dataset.core.model.DatasetProblem; + +/** + * The result of scanning a flat XML document once, left to right. + * + * @param doctype The parsed DOCTYPE declaration, or null when there is none. + * @param root The root element, or null when no root element was found. + * @param elements The root's children, in document order. + * @param problems The problems found while scanning. + * @param wellFormed False when a problem that blocks editing was found; root and elements then hold only + * what was parsed before that problem. + */ +record FlatXmlParseResult(FlatXmlDoctype doctype, FlatXmlRoot root, List elements, + List problems, boolean wellFormed) +{ +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlParser.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlParser.java new file mode 100644 index 00000000..264c05f3 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlParser.java @@ -0,0 +1,737 @@ +/* + * + * 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.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import org.dbunit.eclipse.dataset.core.model.DatasetProblem; +import org.dbunit.eclipse.dataset.core.model.ProblemCode; +import org.dbunit.eclipse.dataset.core.model.ProblemSeverity; + +/** + * Scans dbUnit flat XML text once, left to right, with a hand-written scanner instead of a standard XML + * parser, because edits need exact offsets of every element, attribute name, and raw attribute value. + */ +final class FlatXmlParser +{ + private FlatXmlParser() + { + } + + /** + * Parses flat XML text. + * + * @param text The document text; its offsets equal {@code IDocument} offsets. + * @return The parse result. + */ + static FlatXmlParseResult parse(final CharSequence text) + { + return new Scanner(text).scan(); + } + + /** + * Holds the mutable state of one scan. A fresh instance is used for each call to {@link #parse}. + */ + private static final class Scanner + { + private final CharSequence text; + + private final int length; + + private final List elements = new ArrayList<>(); + + private final List problems = new ArrayList<>(); + + private int pos; + + private FlatXmlDoctype doctype; + + private FlatXmlRoot root; + + private boolean wellFormed = true; + + private Scanner(final CharSequence text) + { + this.text = text; + this.length = text.length(); + } + + private FlatXmlParseResult scan() + { + try + { + skipBom(); + scanProlog(); + scanRoot(); + scanEpilog(); + } + catch (final StopScanException ignored) + { + // The scan stopped at the first blocking problem; the fields already hold the partial + // result. + } + return new FlatXmlParseResult(doctype, root, List.copyOf(elements), List.copyOf(problems), + wellFormed); + } + + private void skipBom() + { + if (length > 0 && text.charAt(0) == '') + { + pos = 1; + } + } + + private void scanProlog() + { + while (true) + { + skipWhitespace(); + if (pos >= length) + { + throw blockingError(ProblemCode.ROOT_NOT_DATASET, + "The document has no root element; a dataset starts with .", pos); + } + if (isElementStart()) + { + return; + } + if (!scanPrologMarkup()) + { + throw blockingError(ProblemCode.NOT_WELL_FORMED, + "Unexpected character before the root element.", pos); + } + } + } + + /** + * Scans the processing instruction, comment, or DOCTYPE declaration at the current position; a second + * DOCTYPE declaration is not one. + * + * @return True when one was scanned, false when the text at the current position is none of them. + */ + private boolean scanPrologMarkup() + { + boolean scanned = true; + if (matchesAt(pos, ", but found <" + startTag.name() + ">.", + rootOffset); + } + if (startTag.selfClosing()) + { + root = new FlatXmlRoot(rootOffset, startTag.startTagEndOffset(), true, -1, + startTag.startTagEndOffset()); + return; + } + final int endTagOffset = scanChildren("dataset", true); + root = new FlatXmlRoot(rootOffset, startTag.startTagEndOffset(), false, endTagOffset, pos); + } + + private void scanEpilog() + { + while (pos < length) + { + if (isWhitespace(text.charAt(pos))) + { + pos++; + } + else if (matchesAt(pos, "} or {@code />}. Used for both the root and row elements; the caller decides what an + * element's name and attributes mean. + */ + private StartTag scanStartTag() + { + pos++; // consume '<' + final String name = scanName(); + final int nameEndOffset = pos; + final List attributes = new ArrayList<>(); + final Set seenNames = new HashSet<>(); + int attributesEndOffset = nameEndOffset; + while (true) + { + final int segmentOffset = pos; + skipWhitespace(); + final boolean hadWhitespace = pos > segmentOffset; + if (matchesAt(pos, "/>")) + { + pos += 2; + return new StartTag(name, nameEndOffset, List.copyOf(attributes), attributesEndOffset, + pos, true); + } + if (matchesAt(pos, ">")) + { + pos++; + return new StartTag(name, nameEndOffset, List.copyOf(attributes), attributesEndOffset, + pos, false); + } + if (pos >= length) + { + throw blockingError(ProblemCode.NOT_WELL_FORMED, "The document ended inside a start " + + "tag.", pos); + } + if (!hadWhitespace) + { + throw blockingError(ProblemCode.NOT_WELL_FORMED, + attributes.isEmpty() + ? "Expected whitespace, '/>', or '>' after the element name." + : "Expected whitespace between attributes.", + pos); + } + final FlatXmlAttribute attribute = scanAttribute(segmentOffset); + if (!seenNames.add(attribute.name())) + { + throw blockingError(ProblemCode.NOT_WELL_FORMED, + "Duplicate attribute '" + attribute.name() + "'.", attribute.nameOffset()); + } + attributes.add(attribute); + attributesEndOffset = attribute.endOffset(); + } + } + + private FlatXmlAttribute scanAttribute(final int segmentOffset) + { + final int nameOffset = pos; + final String name = scanName(); + skipWhitespace(); + if (pos >= length || text.charAt(pos) != '=') + { + throw blockingError(ProblemCode.NOT_WELL_FORMED, "Expected '=' after the attribute name.", + pos); + } + pos++; // consume '=' + skipWhitespace(); + if (pos >= length || (text.charAt(pos) != '"' && text.charAt(pos) != '\'')) + { + throw blockingError(ProblemCode.NOT_WELL_FORMED, "Expected a quoted attribute value.", + pos); + } + final char quote = text.charAt(pos); + pos++; // consume the opening quote + final int valueOffset = pos; + while (pos < length) + { + final char valueChar = text.charAt(pos); + if (valueChar == quote) + { + break; + } + if (valueChar == '<') + { + throw blockingError(ProblemCode.NOT_WELL_FORMED, + "'<' is not allowed in an attribute value.", pos); + } + pos++; + } + if (pos >= length) + { + throw blockingError(ProblemCode.NOT_WELL_FORMED, + "The attribute value has no closing quote.", valueOffset - 1); + } + final int valueEndOffset = pos; + final String value = decodeValue(text.subSequence(valueOffset, valueEndOffset), valueOffset); + pos++; // consume the closing quote + return new FlatXmlAttribute(name, value, segmentOffset, nameOffset, valueOffset, valueEndOffset, + quote); + } + + private String decodeValue(final CharSequence raw, final int valueOffset) + { + try + { + return AttributeValueCodec.decode(raw); + } + catch (final AttributeValueException e) + { + final ProblemCode code = + e.isUnsupportedEntity() ? ProblemCode.UNSUPPORTED_ENTITY : ProblemCode.NOT_WELL_FORMED; + throw blockingError(code, e.getMessage(), valueOffset + e.getOffset(), e); + } + } + + /** + * Scans the children of an element, from just after its start tag's {@code >} to just after its + * end tag's {@code >}. When allowElements is true, a bare {@code <} starts a row element + * (scanned recursively); otherwise it is a nested element, which flat XML does not support. + * + * @return The offset of the end tag's opening angle bracket. + */ + private int scanChildren(final String endTagName, final boolean allowElements) + { + int runStart = -1; + boolean runSignificant = false; + while (true) + { + if (pos >= length) + { + throw blockingError(ProblemCode.NOT_WELL_FORMED, + "The document ended before .", pos); + } + final char ch = text.charAt(pos); + if (ch != '<') + { + // Whitespace does not end a run of text (rule 5): only markup does, so that + // "stray text" reports as one problem rather than one per word. + if (runStart < 0) + { + runStart = pos; + } + if (!isWhitespace(ch)) + { + runSignificant = true; + } + pos++; + continue; + } + if (matchesAt(pos, "= length || text.charAt(pos) != '>') + { + throw blockingError(ProblemCode.NOT_WELL_FORMED, "Expected '>' to close .", + pos); + } + pos++; // consume '>' + if (!name.equals(expectedName)) + { + throw blockingError(ProblemCode.NOT_WELL_FORMED, + "Expected , but found .", endTagStart); + } + return endTagStart; + } + + private void flushTextRun(final int start, final boolean significant, final int end) + { + if (start >= 0 && significant) + { + addInfoProblem(ProblemCode.TEXT_CONTENT_IGNORED, "Text content is ignored.", start, + end - start); + } + } + + private void scanDoctype() + { + final int doctypeOffset = pos; + pos += "= length + || !XmlNames.isNameStartChar(Character.codePointAt(text, pos))) + { + throw blockingError(ProblemCode.NOT_WELL_FORMED, + "Expected whitespace and the root name after = length || text.charAt(pos) != '>') + { + throw blockingError(ProblemCode.NOT_WELL_FORMED, + "Expected '>' to close the DOCTYPE declaration.", pos); + } + pos++; // consume '>' + doctype = new FlatXmlDoctype(rootName, publicId, systemId, internalSubset, internalSubsetOffset, + doctypeOffset, pos); + } + + private String scanQuotedLiteral() + { + if (pos >= length || (text.charAt(pos) != '"' && text.charAt(pos) != '\'')) + { + throw blockingError(ProblemCode.NOT_WELL_FORMED, "Expected a quoted literal.", pos); + } + final char quote = text.charAt(pos); + pos++; + final int start = pos; + while (pos < length && text.charAt(pos) != quote) + { + pos++; + } + if (pos >= length) + { + throw blockingError(ProblemCode.NOT_WELL_FORMED, "The literal has no closing quote.", + start - 1); + } + final String value = text.subSequence(start, pos).toString(); + pos++; // consume the closing quote + return value; + } + + /** + * Advances to the ']' that closes an internal subset, skipping quoted literals, comments, and + * processing instructions so that '>' and ']' inside them do not end it early. + */ + private void skipToMatchingCloseBracket() + { + while (pos < length && text.charAt(pos) != ']') + { + final char ch = text.charAt(pos); + if (ch == '"' || ch == '\'') + { + pos++; + while (pos < length && text.charAt(pos) != ch) + { + pos++; + } + if (pos >= length) + { + throw blockingError(ProblemCode.NOT_WELL_FORMED, + "A quoted literal in the internal subset has no closing quote.", pos); + } + pos++; // consume the closing quote + } + else if (matchesAt(pos, "")) + { + pos++; + } + if (pos >= length) + { + throw blockingError(ProblemCode.NOT_WELL_FORMED, "The comment has no closing '-->'.", + start); + } + pos += 3; + } + + private void skipCData() + { + final int start = pos; + pos += "")) + { + pos++; + } + if (pos >= length) + { + throw blockingError(ProblemCode.NOT_WELL_FORMED, + "The CDATA section has no closing ']]>'.", start); + } + pos += 3; + } + + private void addInfoProblem(final ProblemCode code, final String message, final int offset, + final int problemLength) + { + problems.add(new DatasetProblem(code, ProblemSeverity.INFO, message, null, null, -1, offset, + problemLength)); + } + + private StopScanException blockingError(final ProblemCode code, final String message, + final int offset) + { + return blockingError(code, message, offset, null); + } + + private StopScanException blockingError(final ProblemCode code, final String message, + final int offset, final Throwable cause) + { + problems.add(new DatasetProblem(code, ProblemSeverity.ERROR, withLocation(message, offset), + null, null, -1, offset, 0)); + wellFormed = false; + return new StopScanException(cause); + } + + private String withLocation(final String message, final int offset) + { + int line = 1; + int column = 1; + int index = 0; + final int end = Math.min(offset, length); + while (index < end) + { + final char ch = text.charAt(index); + if (ch == '\n') + { + line++; + column = 1; + index++; + } + else if (ch == '\r') + { + line++; + column = 1; + index++; + if (index < end && text.charAt(index) == '\n') + { + index++; + } + } + else + { + column++; + index++; + } + } + return message + " (line " + line + ", column " + column + ")"; + } + } + + /** + * The parsed head of an element: its name and attributes, before the caller decides what they mean. + */ + private record StartTag(String name, int nameEndOffset, List attributes, + int attributesEndOffset, int startTagEndOffset, boolean selfClosing) + { + } + + /** + * Thrown internally to unwind the scan as soon as a blocking problem is recorded; carries no message + * or stack trace of its own, only the exception behind the problem when there is one. + */ + private static final class StopScanException extends RuntimeException + { + private static final long serialVersionUID = 1L; + + private StopScanException(final Throwable cause) + { + super(null, cause, false, false); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlRoot.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlRoot.java new file mode 100644 index 00000000..1df1a66f --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlRoot.java @@ -0,0 +1,34 @@ +/* + * + * 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 dataset root element. + * + * @param offset The offset of the start tag's opening angle bracket. + * @param startTagEndOffset The offset just after the start tag's closing {@code >} or {@code />}. + * @param selfClosing True when the root was written as a self-closing tag. + * @param endTagOffset The offset of the end tag's opening angle bracket, or -1 when selfClosing is true. + * @param endOffset The offset just after the whole element. + */ +record FlatXmlRoot(int offset, int startTagEndOffset, boolean selfClosing, int endTagOffset, int endOffset) +{ +} 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/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..00d0391c --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlParserTest.java @@ -0,0 +1,441 @@ +/* + * + * 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.StandardCharsets; +import java.util.List; + +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; + +/** + * Tests {@link FlatXmlParser} against the scanning rules of the flat XML specification. + */ +class FlatXmlParserTest +{ + @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); + + 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_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(" 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; + } + } +} 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/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/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/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/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 @@ + + + ")) + { + pos++; + } + if (pos < length) + { + pos += "-->".length(); + } + } + + private void skipProcessingInstruction() + { + pos += "")) + { + pos++; + } + if (pos < length) + { + pos += "?>".length(); + } + } + + private static boolean isQuote(final char ch) + { + return ch == '"' || ch == '\''; + } + + private static boolean isQuantifier(final char ch) + { + return ch == '*' || ch == '?' || ch == '+'; + } + + private static List extractContentModelNames(final String contentSpec) + { + final List names = new ArrayList<>(); + final StringBuilder token = new StringBuilder(); + for (int i = 0; i < contentSpec.length(); i++) + { + final char ch = contentSpec.charAt(i); + if (isStructuralChar(ch) || Character.isWhitespace(ch)) + { + addToken(names, token); + } + else + { + token.append(ch); + } + } + addToken(names, token); + return names; + } + + private static boolean isStructuralChar(final char ch) + { + return ch == '(' || ch == ')' || ch == '*' || ch == '?' || ch == '+' || ch == ',' || ch == '|'; + } + + private static void addToken(final List names, final StringBuilder token) + { + if (token.length() > 0) + { + final String value = token.toString(); + if (!"#PCDATA".equals(value)) + { + names.add(value); + } + token.setLength(0); + } + } + + private void addInfoProblem(final String message, final int offset, final int problemLength) + { + problems.add(new DatasetProblem(ProblemCode.UNSUPPORTED_DTD_CONSTRUCT, ProblemSeverity.INFO, + message, null, null, -1, offset, problemLength)); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/dtd/DtdSource.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/dtd/DtdSource.java new file mode 100644 index 00000000..78e33bec --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/dtd/DtdSource.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.core.dtd; + +import java.util.Optional; + +/** + * Loads the text of an external DTD referenced by a DOCTYPE. + * + * @since 1.0.0 + */ +@FunctionalInterface +public interface DtdSource +{ + /** + * A source that never finds a DTD. + */ + DtdSource NONE = (publicId, systemId) -> Optional.empty(); + + /** + * Loads a DTD's text. + * + * @param publicId The DOCTYPE's public identifier, or null when there is none. + * @param systemId The DOCTYPE's system identifier, or null when there is none. + * @return The DTD's text, or an empty {@link Optional} when it could not be read. + */ + Optional load(String publicId, String systemId); +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/dtd/DtdTable.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/dtd/DtdTable.java new file mode 100644 index 00000000..fd1d6f7b --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/dtd/DtdTable.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.core.dtd; + +import java.util.List; + +/** + * One table the DTD declares for the dataset: the element name and its columns, in DTD order. + * + * @param name The element name. + * @param columns The column names, in declared order; the table keeps its own unmodifiable copy. + * @since 1.0.0 + */ +public record DtdTable(String name, List columns) +{ + /** + * Creates a table with its own unmodifiable copy of the column names, so that neither the caller who + * created it nor a caller of {@link #columns()} can change it. + */ + public DtdTable + { + columns = List.copyOf(columns); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/dtd/DtdReaderTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/dtd/DtdReaderTest.java new file mode 100644 index 00000000..3d816323 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/dtd/DtdReaderTest.java @@ -0,0 +1,226 @@ +/* + * + * 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.List; + +import org.dbunit.eclipse.dataset.core.model.DatasetProblem; +import org.dbunit.eclipse.dataset.core.model.ProblemCode; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link DtdReader} against both layouts dbUnit's {@code FlatDtdWriter} produces and the DTD + * constructs {@link DtdDeclarations#tables()} and {@link DtdDeclarations#merge} must handle. + */ +class DtdReaderTest +{ + @Test + void testRead_whenContentModelIsASequence_extractsNamesInOrder() + { + final DtdDeclarations declarations = DtdReader + .read("\n\n" + + ""); + + assertThat(declarations.tables()).as("The sequence content model must list both tables in order.") + .extracting(DtdTable::name).containsExactly("USERS", "ORDERS"); + } + + @Test + void testRead_whenContentModelIsAChoice_extractsNamesInOrder() + { + final DtdDeclarations declarations = DtdReader.read( + "\n\n"); + + assertThat(declarations.tables()).as("The choice content model must list both tables in order.") + .extracting(DtdTable::name).containsExactly("USERS", "ORDERS"); + } + + @Test + void testRead_whenContentModelRepeatsAName_listsItOnce() + { + final DtdDeclarations declarations = DtdReader + .read("\n\n"); + + assertThat(declarations.tables()).as("A repeated content model name must be listed only once.") + .extracting(DtdTable::name).containsExactly("A", "B"); + } + + @Test + void testRead_whenAttributesHaveEveryTypeAndDefaultVariant_extractsAllColumnNames() + { + final DtdDeclarations declarations = DtdReader.read("\n" + + "\n" + ""); + + final List tables = declarations.tables(); + + assertThat(tables).as("There must be exactly one table.").hasSize(1); + assertThat(tables.get(0).columns()).as( + "Every attribute must contribute its name regardless of its type or default.") + .containsExactly("ID", "NAME", "STATUS", "KIND"); + } + + @Test + void testRead_whenSeveralAttlistsDeclareOneElement_accumulatesColumnsInOrder() + { + final DtdDeclarations declarations = + DtdReader.read("\n\n" + + "\n"); + + assertThat(declarations.tables().get(0).columns()) + .as("Columns from several ATTLISTs of the same element must accumulate in order.") + .containsExactly("ID", "NAME"); + } + + @Test + void testRead_whenAttlistHasNoElementDeclaration_stillDeclaresTheElement() + { + final DtdDeclarations declarations = + DtdReader.read("\n"); + + assertThat(declarations.tables().get(0).columns()) + .as("An ATTLIST alone must declare the element.").containsExactly("ID"); + } + + @Test + void testRead_whenElementHasNoAttlist_hasNoColumns() + { + final DtdDeclarations declarations = + DtdReader.read("\n"); + + assertThat(declarations.tables().get(0).columns()) + .as("An ELEMENT without an ATTLIST has no columns.").isEmpty(); + } + + @Test + void testRead_whenACommentContainsAGreaterThanCharacter_isSkippedWhole() + { + final DtdDeclarations declarations = DtdReader + .read("\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_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_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_keepsTheFirstAndAppendsLaterColumns() + { + final DtdDeclarations internalSubset = DtdReader + .read("\n"); + final DtdDeclarations externalDtd = + DtdReader.read("\n"); + + final DtdDeclarations merged = internalSubset.merge(externalDtd); + + assertThat(merged.tables()).as("The first (internal subset) content model must win.") + .extracting(DtdTable::name).containsExactly("USERS"); + 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"); + } +} 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..9e9dfe47 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/dtd/DtdTableTest.java @@ -0,0 +1,57 @@ +/* + * + * 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.List; + +import org.junit.jupiter.api.Test; + +/** + * Tests that {@link DtdTable} keeps its column list 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); + } +} From 8a93beeeda4ccd4fab40e0f827935692231ff0f2 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Thu, 24 Sep 2026 07:05:32 -0500 Subject: [PATCH 005/170] feat(flatxml): Build dataset model from flat XML with dbUnit semantics * Add FlatXmlModelBuilder.build(text, parse, dtd, options, pendingColumns), grouping elements into tables by case-adjusted key, splitting rows from markers, and building each table's columns: DTD-declared columns in DTD order when the table is a DTD table, then undeclared columns in first-seen order, then pending columns not already present. A later attribute whose name matches only in case overwrites an earlier one's value. DTD tables without elements are appended last, declared-only. The model carries no problems; only parse.wellFormed() decides isEditable(), since every blocking code originates in the parser. Add text as a build() parameter, since FlatXmlIndex needs the exact text that was parsed. * Add FlatXmlIndex, giving the later edit engine each table's display name, row elements, and marker elements, plus the root, DOCTYPE, and text snapshot; kept package-private, like the parse result types. * Add FlatXmlOptions (caseSensitiveTableNames, columnSensing, and the DBUNIT_DEFAULTS constant), public since it is exposed through FlatXmlDatasetDocument's public API. * Add FlatXmlDbUnitParityTest, comparing the model against real dbUnit 3.5.2 for a range of flat XML fixtures: table names and order, row counts, and every value, on every column for fixtures without a DTD and on the declared columns for fixtures with one (dbUnit ignores the rest); also covers a CR LF rewrite of editor-sample.xml and confirms dbUnit itself fails internal-subset.xml under column sensing. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr --- .../dataset/core/flatxml/FlatXmlIndex.java | 113 +++++++ .../core/flatxml/FlatXmlModelBuilder.java | 285 ++++++++++++++++++ .../dataset/core/flatxml/FlatXmlOptions.java | 38 +++ .../core/flatxml/FlatXmlDbUnitParityTest.java | 193 ++++++++++++ .../core/flatxml/FlatXmlModelBuilderTest.java | 253 ++++++++++++++++ 5 files changed, 882 insertions(+) create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlIndex.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlModelBuilder.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlOptions.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDbUnitParityTest.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlModelBuilderTest.java diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlIndex.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlIndex.java new file mode 100644 index 00000000..35bbb81d --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlIndex.java @@ -0,0 +1,113 @@ +/* + * + * 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.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Everything the edit engine needs from a parse that the dataset model itself does not keep: the root, + * the DOCTYPE, the text that was parsed, and, per table key, the display name, the row elements, and the + * marker elements. The edit engine takes all offsets from this index. + */ +final class FlatXmlIndex +{ + private final FlatXmlRoot root; + + private final FlatXmlDoctype doctype; + + private final String text; + + private final List elements; + + private final Map displayNames; + + private final Map> rowElements; + + private final Map> markerElements; + + FlatXmlIndex(final FlatXmlRoot root, final FlatXmlDoctype doctype, final String text, + final List elements, final Map displayNames, + final Map> rowElements, + final Map> markerElements) + { + this.root = root; + this.doctype = doctype; + this.text = text; + this.elements = List.copyOf(elements); + this.displayNames = new LinkedHashMap<>(displayNames); + this.rowElements = copyOfElementLists(rowElements); + this.markerElements = copyOfElementLists(markerElements); + } + + FlatXmlRoot getRoot() + { + return root; + } + + FlatXmlDoctype getDoctype() + { + return doctype; + } + + String getText() + { + return text; + } + + List getElements() + { + return elements; + } + + Set getTableKeys() + { + return displayNames.keySet(); + } + + String getDisplayName(final String tableKey) + { + return displayNames.get(tableKey); + } + + List getRowElements(final String tableKey) + { + return rowElements.getOrDefault(tableKey, List.of()); + } + + List getMarkerElements(final String tableKey) + { + return markerElements.getOrDefault(tableKey, List.of()); + } + + private static Map> copyOfElementLists( + final Map> source) + { + final Map> copy = new LinkedHashMap<>(); + for (final Map.Entry> entry : source.entrySet()) + { + copy.put(entry.getKey(), List.copyOf(entry.getValue())); + } + return copy; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlModelBuilder.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlModelBuilder.java new file mode 100644 index 00000000..48035035 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlModelBuilder.java @@ -0,0 +1,285 @@ +/* + * + * 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.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.dbunit.eclipse.dataset.core.dtd.DtdDeclarations; +import org.dbunit.eclipse.dataset.core.dtd.DtdTable; +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; + +/** + * Builds a dataset model and its index from a parsed flat XML document, applying dbUnit's table and + * column grouping, DTD columns, and pending columns. + */ +final class FlatXmlModelBuilder +{ + private FlatXmlModelBuilder() + { + } + + /** + * Builds the model and index of a parsed flat XML document. The model carries no problems; the + * validator adds those separately. + * + * @param text The exact text that was parsed. + * @param parse The parse result to build from. + * @param dtd The merged DTD declarations, or null when the document has no DOCTYPE. + * @param options The case-sensitivity and column-sensing options. + * @param pendingColumns Each table's pending columns, keyed by table key, in the order they were + * added. + * @return The model and the index the edit engine takes offsets from. + */ + static Result build(final String text, final FlatXmlParseResult parse, final DtdDeclarations dtd, + final FlatXmlOptions options, final Map> pendingColumns) + { + final Map dtdTablesByKey = indexDtdTables(dtd, options); + final Map groups = groupElementsByTable(parse.elements(), options); + + final List tables = new ArrayList<>(); + for (final Map.Entry entry : groups.entrySet()) + { + final String key = entry.getKey(); + final TableGroup group = entry.getValue(); + final DtdTable dtdTable = dtdTablesByKey.get(key); + final List columns = buildColumns(group.rowElements, dtdTable, + pendingColumns.getOrDefault(key, List.of())); + final RowsResult rowsResult = buildRows(group.rowElements, columns); + tables.add(new DatasetTable(key, group.displayName, + toDatasetColumns(columns, rowsResult.hasValues), rowsResult.rows, false)); + } + addDtdOnlyTables(tables, dtd, options, groups, pendingColumns); + + final DatasetModel model = new DatasetModel(tables, List.of(), parse.wellFormed()); + final FlatXmlIndex index = buildIndex(parse, text, groups); + return new Result(model, index); + } + + private static Map indexDtdTables(final DtdDeclarations dtd, + final FlatXmlOptions options) + { + final Map byKey = new LinkedHashMap<>(); + if (dtd != null) + { + for (final DtdTable table : dtd.tables()) + { + byKey.put(tableKey(table.name(), options), table); + } + } + return byKey; + } + + private static Map groupElementsByTable(final List elements, + final FlatXmlOptions options) + { + final Map groups = new LinkedHashMap<>(); + for (final FlatXmlElement element : elements) + { + final String key = tableKey(element.name(), options); + final TableGroup group = groups.computeIfAbsent(key, unused -> new TableGroup(element.name())); + if (element.attributes().isEmpty()) + { + group.markerElements.add(element); + } + else + { + group.rowElements.add(element); + } + } + return groups; + } + + private static List buildColumns(final List rowElements, + final DtdTable dtdTable, final List pendingNames) + { + final Map columns = new LinkedHashMap<>(); + if (dtdTable != null) + { + for (final String declaredName : dtdTable.columns()) + { + columns.put(columnKey(declaredName), new ColumnInfo(declaredName, true, false)); + } + } + for (final FlatXmlElement row : rowElements) + { + for (final FlatXmlAttribute attribute : row.attributes()) + { + columns.putIfAbsent(columnKey(attribute.name()), + new ColumnInfo(attribute.name(), false, false)); + } + } + for (final String pendingName : pendingNames) + { + columns.putIfAbsent(columnKey(pendingName), new ColumnInfo(pendingName, false, true)); + } + return List.copyOf(columns.values()); + } + + private static RowsResult buildRows(final List rowElements, + final List columns) + { + final Map columnIndexByKey = new LinkedHashMap<>(); + for (int index = 0; index < columns.size(); index++) + { + columnIndexByKey.put(columnKey(columns.get(index).displayName()), index); + } + final boolean[] hasValues = new boolean[columns.size()]; + final List rows = new ArrayList<>(); + for (final FlatXmlElement row : rowElements) + { + final String[] values = new String[columns.size()]; + for (final FlatXmlAttribute attribute : row.attributes()) + { + final Integer columnIndex = columnIndexByKey.get(columnKey(attribute.name())); + if (columnIndex != null) + { + // Attributes are visited in document order, so when two of an element's attributes + // differ only in case, the last one keeps overwriting and so wins. + values[columnIndex] = attribute.value(); + hasValues[columnIndex] = true; + } + } + rows.add(new DatasetRow(Arrays.asList(values))); + } + return new RowsResult(rows, hasValues); + } + + private static List toDatasetColumns(final List columns, + final boolean[] hasValues) + { + final List result = new ArrayList<>(); + for (int index = 0; index < columns.size(); index++) + { + final ColumnInfo column = columns.get(index); + result.add(new DatasetColumn(column.displayName(), column.declared(), hasValues[index], + column.pending())); + } + return List.copyOf(result); + } + + private static void addDtdOnlyTables(final List tables, final DtdDeclarations dtd, + final FlatXmlOptions options, final Map groups, + final Map> pendingColumns) + { + if (dtd == null) + { + return; + } + final Set addedKeys = new HashSet<>(); + for (final DtdTable dtdTable : dtd.tables()) + { + final String key = tableKey(dtdTable.name(), options); + if (groups.containsKey(key) || !addedKeys.add(key)) + { + continue; + } + final List columns = + buildColumns(List.of(), dtdTable, pendingColumns.getOrDefault(key, List.of())); + tables.add(new DatasetTable(key, dtdTable.name(), + toDatasetColumns(columns, new boolean[columns.size()]), List.of(), true)); + } + } + + private static FlatXmlIndex buildIndex(final FlatXmlParseResult parse, final String text, + final Map groups) + { + final Map displayNames = new LinkedHashMap<>(); + final Map> rowElementsByKey = new LinkedHashMap<>(); + final Map> markerElementsByKey = new LinkedHashMap<>(); + for (final Map.Entry entry : groups.entrySet()) + { + final TableGroup group = entry.getValue(); + displayNames.put(entry.getKey(), group.displayName); + rowElementsByKey.put(entry.getKey(), group.rowElements); + markerElementsByKey.put(entry.getKey(), group.markerElements); + } + return new FlatXmlIndex(parse.root(), parse.doctype(), text, parse.elements(), displayNames, + rowElementsByKey, markerElementsByKey); + } + + private static String tableKey(final String name, final FlatXmlOptions options) + { + return options.caseSensitiveTableNames() ? name : name.toUpperCase(Locale.ENGLISH); + } + + private static String columnKey(final String name) + { + return name.toUpperCase(Locale.ENGLISH); + } + + /** + * One table's elements, gathered while walking the document once. + */ + private static final class TableGroup + { + private final String displayName; + + private final List rowElements = new ArrayList<>(); + + private final List markerElements = new ArrayList<>(); + + private TableGroup(final String displayName) + { + this.displayName = displayName; + } + } + + /** + * One column, before its rows are known. + */ + private record ColumnInfo(String displayName, boolean declared, boolean pending) + { + } + + /** + * A table's rows, built while walking its elements once, alongside which columns got a value from + * that same walk. + */ + private static final class RowsResult + { + private final List rows; + + private final boolean[] hasValues; + + private RowsResult(final List rows, final boolean[] hasValues) + { + this.rows = rows; + this.hasValues = hasValues; + } + } + + /** + * The model and index built from one parse. + */ + record Result(DatasetModel model, FlatXmlIndex index) + { + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlOptions.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlOptions.java new file mode 100644 index 00000000..773180e3 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlOptions.java @@ -0,0 +1,38 @@ +/* + * + * 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 options that change how a flat XML document is read, matching dbUnit's + * {@code FlatXmlDataSetBuilder}. + * + * @param caseSensitiveTableNames True when table names must match exactly instead of case-insensitively. + * @param columnSensing True when an attribute missing from a table's first element still becomes a + * column, instead of being ignored. + * @since 1.0.0 + */ +public record FlatXmlOptions(boolean caseSensitiveTableNames, boolean columnSensing) +{ + /** + * dbUnit's own defaults: case-insensitive table names and no column sensing. + */ + public static final FlatXmlOptions DBUNIT_DEFAULTS = new FlatXmlOptions(false, false); +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDbUnitParityTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDbUnitParityTest.java new file mode 100644 index 00000000..12ad41c1 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDbUnitParityTest.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.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +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.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.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; + +/** + * Compares the model built from each "parity" fixture with the dataset real dbUnit 3.5.2 loads from the + * same file, so the editor shows exactly what dbUnit would read. + */ +class FlatXmlDbUnitParityTest +{ + private static final Path DATASETS_DIRECTORY = Path.of("src", "test", "resources", "datasets"); + + @TempDir + private Path tempDir; + + @ParameterizedTest + @ValueSource(strings = { "flatXmlDataSetTest.xml", "flatXmlDataSetDuplicateTest.xml", + "flatXmlDataSetDuplicateMultipleCaseTest.xml", "editor-sample.xml", "column-sensing.xml", + "special-characters.xml" }) + void testBuild_whenFixtureHasNoDoctype_matchesDbUnitOnEveryColumn(final String fixtureName) + throws Exception + { + assertParity(TestDatasets.read(fixtureName), datasetsFile(fixtureName), true, true); + } + + @ParameterizedTest + @ValueSource(strings = { "flatXmlTableTest.xml", "flatXmlDataSetDtdDifferentCaseTest.xml", + "internal-subset.xml" }) + void testBuild_whenFixtureHasADoctype_matchesDbUnitOnDeclaredColumns(final String fixtureName) + throws Exception + { + assertParity(TestDatasets.read(fixtureName), datasetsFile(fixtureName), false, false); + } + + @Test + void testBuild_whenTheIso88591FixtureIsRead_matchesDbUnitOnEveryColumn() throws Exception + { + final String text = TestDatasets.read("iso-8859-1.xml", StandardCharsets.ISO_8859_1); + + assertParity(text, datasetsFile("iso-8859-1.xml"), true, true); + } + + @Test + void testBuild_whenEditorSampleIsRewrittenWithCrLf_matchesDbUnitOnEveryColumn() 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); + + assertParity(crlfText, crlfFile.toFile(), true, true); + } + + @Test + void testBuild_whenInternalSubsetIsLoadedWithColumnSensing_dbUnitThrowsNoSuchColumnException() + { + assertThatThrownBy(() -> new FlatXmlDataSetBuilder().setColumnSensing(true) + .build(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); + } + + private 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.getRows().get(row).getValue(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); + } + } + } + + 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(); + DtdDeclarations declarations = DtdReader.read(internalSubset == null ? "" : internalSubset); + if (parse.doctype().systemId() != null) + { + declarations = + declarations.merge(DtdReader.read(TestDatasets.read(parse.doctype().systemId()))); + } + return declarations; + } + + private 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/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..7e9520a0 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlModelBuilderTest.java @@ -0,0 +1,253 @@ +/* + * + * 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 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.DatasetColumn; +import org.dbunit.eclipse.dataset.core.model.DatasetModel; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link FlatXmlModelBuilder} against the table and column grouping rules of the Model Builder and + * Mapping sections of the flat XML specification. + */ +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_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_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_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_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(); + } +} From 6da4d92fa132e89e0411acacce86c135aac5dfcd Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Thu, 24 Sep 2026 07:14:29 -0500 Subject: [PATCH 006/170] feat(validation): Warn about flat XML content dbUnit ignores * Add FlatXmlValidator.validate(parse, index, tables, dtdState, dtd, options), covering every warning the editor raises for flat XML content dbUnit tolerates or rejects: COLUMN_NOT_IN_FIRST_ROW and FIRST_ELEMENT_WITHOUT_ATTRIBUTES (only without a DTD and without column sensing, the latter suppressing the former for its table), TABLE_NAME_CASE_VARIANTS, COLUMN_NAME_CASE_VARIANTS, DUPLICATE_COLUMN_IN_ROW, TABLE_NOT_DECLARED_IN_DTD, COLUMN_NOT_DECLARED_IN_DTD (WARNING, or ERROR with column sensing), DTD_TABLE_WITHOUT_DECLARATION, and DTD_NOT_LOADED. Parser and DTD reader problems are passed through in the returned list, so it is the complete list for the model. Add dtd as a parameter alongside dtdState, because deciding whether a table or column is DTD-declared needs the declarations themselves, not just the state. * Add DtdState (none, loaded, not loaded). * Add FlatXmlIndex.getAllElementsInOrder(tableKey), merging a table's row and marker elements by offset, so the validator can find a table's first element regardless of which kind it is. * Add the Validator_* messages to the core bundle's Messages and messages.properties, bound with NLS.bind. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr --- .../dbunit/eclipse/dataset/core/Messages.java | 55 +++ .../dataset/core/flatxml/DtdState.java | 42 ++ .../dataset/core/flatxml/FlatXmlIndex.java | 14 + .../core/flatxml/FlatXmlValidator.java | 372 ++++++++++++++++++ .../eclipse/dataset/core/messages.properties | 11 + .../core/flatxml/FlatXmlValidatorTest.java | 280 +++++++++++++ 6 files changed, 774 insertions(+) create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/DtdState.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlValidator.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlValidatorTest.java 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 index b9070520..8790f9f8 100644 --- 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 @@ -31,6 +31,61 @@ 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 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; + static { NLS.initializeMessages(BUNDLE_NAME, Messages.class); diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/DtdState.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/DtdState.java new file mode 100644 index 00000000..2d580b37 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/DtdState.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.core.flatxml; + +/** + * How a document relates to a DTD. + */ +enum DtdState +{ + /** + * The document has no DOCTYPE. + */ + NONE, + + /** + * The document has a DOCTYPE and its declarations are complete. + */ + LOADED, + + /** + * The DOCTYPE has a system identifier that could not be read, so its declarations are incomplete. + */ + NOT_LOADED +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlIndex.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlIndex.java index 35bbb81d..faa0e955 100644 --- a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlIndex.java +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlIndex.java @@ -20,6 +20,8 @@ */ package org.dbunit.eclipse.dataset.core.flatxml; +import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -100,6 +102,18 @@ List getMarkerElements(final String tableKey) return markerElements.getOrDefault(tableKey, List.of()); } + /** + * Returns a table's row and marker elements together, in document order, so callers can find the + * table's first element regardless of whether it is a row or a marker. + */ + List getAllElementsInOrder(final String tableKey) + { + final List combined = new ArrayList<>(getRowElements(tableKey)); + combined.addAll(getMarkerElements(tableKey)); + combined.sort(Comparator.comparingInt(FlatXmlElement::offset)); + return List.copyOf(combined); + } + private static Map> copyOfElementLists( final Map> source) { diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlValidator.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlValidator.java new file mode 100644 index 00000000..4f92be0d --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlValidator.java @@ -0,0 +1,372 @@ +/* + * + * 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.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import org.dbunit.eclipse.dataset.core.Messages; +import org.dbunit.eclipse.dataset.core.dtd.DtdDeclarations; +import org.dbunit.eclipse.dataset.core.dtd.DtdTable; +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +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.core.model.ProblemSeverity; +import org.eclipse.osgi.util.NLS; + +/** + * Warns about flat XML content dbUnit ignores or that would make dbUnit fail to load the dataset, + * without blocking editing. + */ +final class FlatXmlValidator +{ + private FlatXmlValidator() + { + } + + /** + * Validates a built model against dbUnit's flat XML semantics. + * + * @param parse The parse result the model was built from; its own problems are passed through. + * @param index The index of the same parse. + * @param tables The model's tables. + * @param dtdState How the document relates to a DTD. + * @param dtd The merged DTD declarations, or null when dtdState is {@link DtdState#NONE}; its own + * problems are passed through. + * @param options The case-sensitivity and column-sensing options. + * @return The parser's and the DTD reader's problems, followed by the problems found here. + */ + static List validate(final FlatXmlParseResult parse, final FlatXmlIndex index, + final List tables, final DtdState dtdState, final DtdDeclarations dtd, + final FlatXmlOptions options) + { + final List problems = new ArrayList<>(parse.problems()); + if (dtd != null) + { + problems.addAll(dtd.getProblems()); + } + final Set dtdTableKeys = dtdTableKeys(dtdState, dtd, options); + for (final DatasetTable table : tables) + { + validateFirstRowColumns(problems, index, table, dtdState, options); + validateTableNameCaseVariants(problems, index, table, options); + validateColumnNameCaseVariants(problems, index, table); + validateDuplicateColumnInRow(problems, index, table); + validateTableNotDeclaredInDtd(problems, index, table, dtdState, dtdTableKeys); + validateColumnNotDeclaredInDtd(problems, index, table, dtdState, options, dtdTableKeys); + validateRedundantEmptyElement(problems, index, table); + } + validateDtdTableWithoutDeclaration(problems, index, dtdState, dtd); + validateDtdNotLoaded(problems, index, dtdState); + return List.copyOf(problems); + } + + private static Set dtdTableKeys(final DtdState dtdState, final DtdDeclarations dtd, + final FlatXmlOptions options) + { + final Set keys = new HashSet<>(); + if (dtdState == DtdState.LOADED && dtd != null) + { + for (final DtdTable dtdTable : dtd.tables()) + { + keys.add(tableKey(dtdTable.name(), options)); + } + } + return keys; + } + + private static void validateFirstRowColumns(final List problems, + final FlatXmlIndex index, final DatasetTable table, final DtdState dtdState, + final FlatXmlOptions options) + { + if (dtdState != DtdState.NONE || options.columnSensing()) + { + return; + } + final List elements = index.getAllElementsInOrder(table.getKey()); + if (elements.isEmpty()) + { + return; + } + final FlatXmlElement first = elements.get(0); + if (first.attributes().isEmpty()) + { + if (!table.getRows().isEmpty()) + { + problems.add(new DatasetProblem(ProblemCode.FIRST_ELEMENT_WITHOUT_ATTRIBUTES, + ProblemSeverity.WARNING, + NLS.bind(Messages.Validator_firstElementWithoutAttributes, table.getName()), + table.getKey(), null, -1, first.offset(), + first.nameEndOffset() - first.offset())); + } + return; + } + final Set firstElementColumnKeys = new HashSet<>(); + for (final FlatXmlAttribute attribute : first.attributes()) + { + firstElementColumnKeys.add(attribute.name().toUpperCase(Locale.ENGLISH)); + } + final List rowElements = index.getRowElements(table.getKey()); + for (final DatasetColumn column : table.getColumns()) + { + final String columnKey = column.name().toUpperCase(Locale.ENGLISH); + if (firstElementColumnKeys.contains(columnKey)) + { + continue; + } + reportFirstLaterValue(problems, table, rowElements, columnKey, column.name()); + } + } + + private static void reportFirstLaterValue(final List problems, + final DatasetTable table, final List rowElements, final String columnKey, + final String columnName) + { + for (int rowIndex = 1; rowIndex < rowElements.size(); rowIndex++) + { + final FlatXmlAttribute attribute = findAttribute(rowElements.get(rowIndex), columnKey); + if (attribute != null) + { + problems.add(new DatasetProblem(ProblemCode.COLUMN_NOT_IN_FIRST_ROW, + ProblemSeverity.WARNING, + NLS.bind(Messages.Validator_columnNotInFirstRow, columnName, table.getName()), + table.getKey(), columnName, rowIndex, attribute.nameOffset(), + attribute.name().length())); + return; + } + } + } + + private static void validateTableNameCaseVariants(final List problems, + final FlatXmlIndex index, final DatasetTable table, final FlatXmlOptions options) + { + if (options.caseSensitiveTableNames()) + { + return; + } + for (final FlatXmlElement element : index.getAllElementsInOrder(table.getKey())) + { + if (!element.name().equals(table.getName())) + { + problems.add(new DatasetProblem(ProblemCode.TABLE_NAME_CASE_VARIANTS, + ProblemSeverity.WARNING, + NLS.bind(Messages.Validator_tableNameCaseVariants, table.getName(), + element.name()), + table.getKey(), null, -1, element.offset(), + element.nameEndOffset() - element.offset())); + return; + } + } + } + + private static void validateColumnNameCaseVariants(final List problems, + final FlatXmlIndex index, final DatasetTable table) + { + for (final DatasetColumn column : table.getColumns()) + { + final String columnKey = column.name().toUpperCase(Locale.ENGLISH); + final FlatXmlAttribute variant = findSpellingVariant(index.getRowElements(table.getKey()), + columnKey, column.name()); + if (variant != null) + { + problems.add(new DatasetProblem(ProblemCode.COLUMN_NAME_CASE_VARIANTS, + ProblemSeverity.WARNING, + NLS.bind(Messages.Validator_columnNameCaseVariants, + new Object[] { column.name(), table.getName(), variant.name() }), + table.getKey(), column.name(), -1, variant.nameOffset(), + variant.name().length())); + } + } + } + + private static FlatXmlAttribute findSpellingVariant(final List rowElements, + final String columnKey, final String columnName) + { + for (final FlatXmlElement row : rowElements) + { + for (final FlatXmlAttribute attribute : row.attributes()) + { + if (attribute.name().toUpperCase(Locale.ENGLISH).equals(columnKey) + && !attribute.name().equals(columnName)) + { + return attribute; + } + } + } + return null; + } + + private static void validateDuplicateColumnInRow(final List problems, + final FlatXmlIndex index, final DatasetTable table) + { + for (final FlatXmlElement row : index.getRowElements(table.getKey())) + { + final Set seen = new HashSet<>(); + for (final FlatXmlAttribute attribute : row.attributes()) + { + if (!seen.add(attribute.name().toUpperCase(Locale.ENGLISH))) + { + problems.add(new DatasetProblem(ProblemCode.DUPLICATE_COLUMN_IN_ROW, + ProblemSeverity.WARNING, + NLS.bind(Messages.Validator_duplicateColumnInRow, attribute.name()), + table.getKey(), null, -1, attribute.nameOffset(), + attribute.name().length())); + } + } + } + } + + private static void validateTableNotDeclaredInDtd(final List problems, + final FlatXmlIndex index, final DatasetTable table, final DtdState dtdState, + final Set dtdTableKeys) + { + if (dtdState != DtdState.LOADED || table.isDeclaredOnly() || dtdTableKeys.contains(table.getKey())) + { + return; + } + final List elements = index.getAllElementsInOrder(table.getKey()); + if (elements.isEmpty()) + { + return; + } + final FlatXmlElement first = elements.get(0); + problems.add(new DatasetProblem(ProblemCode.TABLE_NOT_DECLARED_IN_DTD, ProblemSeverity.ERROR, + NLS.bind(Messages.Validator_tableNotDeclaredInDtd, table.getName()), table.getKey(), null, + -1, first.offset(), first.nameEndOffset() - first.offset())); + } + + private static void validateColumnNotDeclaredInDtd(final List problems, + final FlatXmlIndex index, final DatasetTable table, final DtdState dtdState, + final FlatXmlOptions options, final Set dtdTableKeys) + { + if (dtdState != DtdState.LOADED || !dtdTableKeys.contains(table.getKey())) + { + return; + } + final ProblemSeverity severity = + options.columnSensing() ? ProblemSeverity.ERROR : ProblemSeverity.WARNING; + final List rowElements = index.getRowElements(table.getKey()); + for (final DatasetColumn column : table.getColumns()) + { + if (column.declared()) + { + continue; + } + final String columnKey = column.name().toUpperCase(Locale.ENGLISH); + for (final FlatXmlElement row : rowElements) + { + final FlatXmlAttribute attribute = findAttribute(row, columnKey); + if (attribute != null) + { + final String message = severity == ProblemSeverity.ERROR + ? NLS.bind(Messages.Validator_columnNotDeclaredInDtdError, column.name(), + table.getName()) + : NLS.bind(Messages.Validator_columnNotDeclaredInDtdWarning, column.name(), + table.getName()); + problems.add(new DatasetProblem(ProblemCode.COLUMN_NOT_DECLARED_IN_DTD, severity, + message, table.getKey(), column.name(), -1, attribute.nameOffset(), + attribute.name().length())); + break; + } + } + } + } + + private static void validateDtdTableWithoutDeclaration(final List problems, + final FlatXmlIndex index, final DtdState dtdState, final DtdDeclarations dtd) + { + if (dtdState != DtdState.LOADED || dtd == null) + { + return; + } + for (final String name : dtd.missingDeclarations()) + { + problems.add(new DatasetProblem(ProblemCode.DTD_TABLE_WITHOUT_DECLARATION, + ProblemSeverity.ERROR, NLS.bind(Messages.Validator_dtdTableWithoutDeclaration, name), + null, null, -1, doctypeOffset(index), doctypeLength(index))); + } + } + + private static void validateDtdNotLoaded(final List problems, + final FlatXmlIndex index, final DtdState dtdState) + { + if (dtdState != DtdState.NOT_LOADED) + { + return; + } + problems.add(new DatasetProblem(ProblemCode.DTD_NOT_LOADED, ProblemSeverity.WARNING, + Messages.Validator_dtdNotLoaded, null, null, -1, doctypeOffset(index), + doctypeLength(index))); + } + + private static void validateRedundantEmptyElement(final List problems, + final FlatXmlIndex index, final DatasetTable table) + { + if (table.getRows().isEmpty()) + { + return; + } + final List elements = index.getAllElementsInOrder(table.getKey()); + final FlatXmlElement first = elements.isEmpty() ? null : elements.get(0); + for (final FlatXmlElement marker : index.getMarkerElements(table.getKey())) + { + if (!marker.equals(first)) + { + problems.add(new DatasetProblem(ProblemCode.REDUNDANT_EMPTY_ELEMENT, ProblemSeverity.INFO, + NLS.bind(Messages.Validator_redundantEmptyElement, table.getName()), + table.getKey(), null, -1, marker.offset(), marker.endOffset() - marker.offset())); + } + } + } + + private static FlatXmlAttribute findAttribute(final FlatXmlElement element, final String columnKey) + { + FlatXmlAttribute found = null; + for (final FlatXmlAttribute attribute : element.attributes()) + { + if (attribute.name().toUpperCase(Locale.ENGLISH).equals(columnKey)) + { + found = attribute; + } + } + return found; + } + + private static int doctypeOffset(final FlatXmlIndex index) + { + return index.getDoctype() != null ? index.getDoctype().offset() : -1; + } + + private static int doctypeLength(final FlatXmlIndex index) + { + return index.getDoctype() != null ? index.getDoctype().endOffset() - index.getDoctype().offset() + : 0; + } + + private static String tableKey(final String name, final FlatXmlOptions options) + { + return options.caseSensitiveTableNames() ? name : name.toUpperCase(Locale.ENGLISH); + } +} 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 index e69de29b..6a59e4e0 100644 --- 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 @@ -0,0 +1,11 @@ +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_dtdNotLoaded=The DOCTYPE's external DTD could not be read. +Validator_redundantEmptyElement=This empty element is redundant; table "{0}" already has rows. 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..07f234cb --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlValidatorTest.java @@ -0,0 +1,280 @@ +/* + * + * 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 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.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_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_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_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_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_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); + } +} From 005cbd011c5c8176693d3eb33df12e8da5b94fb4 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Thu, 24 Sep 2026 07:26:30 -0500 Subject: [PATCH 007/170] feat(flatxml): Add dataset document bound to an Eclipse text document * Add the core.edit types: DatasetDocument and TextDatasetDocument, CellChange, DatasetModelListener, DatasetModelChangeEvent, and ChangeOrigin (DatasetEditException already exists). * Add FlatXmlDatasetDocument implementing TextDatasetDocument: a document listener marks the model stale without parsing; refresh() reparses, resolves the DTD (the DOCTYPE's internal subset merged with an external DTD cached by system identifier), builds the model and index, validates, and notifies listeners; setOptions and rebind mark the model stale and refresh; reloadDtd re-reads every cached DTD and refreshes only when a text actually changed; isBlank and createEmptyDataset; locate and cellAt navigate between cells and text offsets, cellAt binary-searching the index's elements. The row, column, and table operations throw UnsupportedOperationException until later commits add them. * Add the private apply(edits), running edits inside one compound undo change and, past 50 edits, one rewrite session, plus the batch(Runnable) that groups nested operations the same way. * Add FlatXmlTextLayout: indentOf, childIndentation, and lineExtent, used by later edit operations to match the surrounding document's formatting. * Export org.dbunit.eclipse.dataset.core.edit and org.dbunit.eclipse.dataset.core.flatxml from the core bundle, both as friends of the UI bundle. * Cover the lifecycle with FlatXmlDatasetDocumentTest, connecting the document undo manager registry where undo matters, and the layout rules with FlatXmlTextLayoutTest, on a plain org.eclipse.jface.text.Document (no workbench needed). Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr --- .../META-INF/MANIFEST.MF | 4 +- .../eclipse/dataset/core/edit/CellChange.java | 33 + .../dataset/core/edit/ChangeOrigin.java | 39 ++ .../dataset/core/edit/DatasetDocument.java | 175 +++++ .../core/edit/DatasetModelChangeEvent.java | 35 + .../core/edit/DatasetModelListener.java | 37 ++ .../core/edit/TextDatasetDocument.java | 67 ++ .../core/flatxml/FlatXmlDatasetDocument.java | 601 ++++++++++++++++++ .../core/flatxml/FlatXmlTextLayout.java | 158 +++++ .../flatxml/FlatXmlDatasetDocumentTest.java | 407 ++++++++++++ .../core/flatxml/FlatXmlTextLayoutTest.java | 157 +++++ 11 files changed, 1712 insertions(+), 1 deletion(-) create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/CellChange.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/ChangeOrigin.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/DatasetDocument.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/DatasetModelChangeEvent.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/DatasetModelListener.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/TextDatasetDocument.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlTextLayout.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocumentTest.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlTextLayoutTest.java 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 54cbf349..ccabf57c 100644 --- a/bundles/org.dbunit.eclipse.dataset.core/META-INF/MANIFEST.MF +++ b/bundles/org.dbunit.eclipse.dataset.core/META-INF/MANIFEST.MF @@ -14,4 +14,6 @@ Require-Bundle: org.eclipse.equinox.common;bundle-version="3.19.0", Import-Package: org.eclipse.osgi.util;version="1.1.0" 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.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" diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/CellChange.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/CellChange.java new file mode 100644 index 00000000..56a35655 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/CellChange.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.edit; + +/** + * A new value for one cell of one row. + * + * @param rowIndex The index of the row within its table. + * @param columnName The name of the column, matched case-insensitively. + * @param value The new value, or null for NULL. + * @since 1.0.0 + */ +public record CellChange(int rowIndex, String columnName, String value) +{ +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/ChangeOrigin.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/ChangeOrigin.java new file mode 100644 index 00000000..cd0c99e7 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/ChangeOrigin.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.edit; + +/** + * What caused a dataset model to change. + * + * @since 1.0.0 + */ +public enum ChangeOrigin +{ + /** + * Caused by a {@link DatasetDocument} edit operation. + */ + EDIT, + + /** + * Caused by any other change of the source: typing, undo, redo, revert, or options. + */ + DOCUMENT +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/DatasetDocument.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/DatasetDocument.java new file mode 100644 index 00000000..53911dfe --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/DatasetDocument.java @@ -0,0 +1,175 @@ +/* + * + * 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 java.util.List; + +import org.dbunit.eclipse.dataset.core.model.DatasetModel; + +/** + * An editable dataset bound to its source. Not thread-safe: use it from one thread (the UI thread in the + * editor). Every edit operation first refreshes a stale model, then validates its arguments, then applies + * all of its changes as one undoable change, then refreshes the model and notifies listeners with + * {@link ChangeOrigin#EDIT}. Invalid arguments or rule violations throw {@link DatasetEditException} and + * change nothing. + * + * @since 1.0.0 + */ +public interface DatasetDocument +{ + /** + * Returns the model of the last refresh. Never reparses. + * + * @return The current model. + */ + DatasetModel getModel(); + + /** + * Returns whether the source changed since the last refresh. + * + * @return True when the source is stale. + */ + boolean isStale(); + + /** + * Rebuilds the model when stale and notifies listeners with {@link ChangeOrigin#DOCUMENT}. Does + * nothing when the model is not stale. + */ + void refresh(); + + /** + * Registers a listener notified after every model change. + * + * @param listener The listener to add. + */ + void addModelListener(DatasetModelListener listener); + + /** + * Unregisters a previously added listener. + * + * @param listener The listener to remove. + */ + void removeModelListener(DatasetModelListener listener); + + /** + * Runs several operations as one undoable change. Batches may nest; only the outermost batch begins + * and ends the undoable change. + * + * @param operations The operations to run. + */ + void batch(Runnable operations); + + /** + * Changes one or more cells of a table. + * + * @param tableKey The key of the table. + * @param changes The changes to apply. + */ + void setCells(String tableKey, List changes); + + /** + * Inserts rows into a table. + * + * @param tableKey The key of the table. + * @param rowIndex The index to insert before; equal to the row count to append. + * @param rows The values of the new rows, aligned with the table's columns. + */ + void insertRows(String tableKey, int rowIndex, List> rows); + + /** + * Inserts copies of rows directly after the last of them, in their order. + * + * @param tableKey The key of the table. + * @param rowIndexes The indexes of the rows to duplicate. + */ + void duplicateRows(String tableKey, int[] rowIndexes); + + /** + * Deletes rows from a table. + * + * @param tableKey The key of the table. + * @param rowIndexes The indexes of the rows to delete. + */ + void deleteRows(String tableKey, int[] rowIndexes); + + /** + * Moves a contiguous block of rows up or down. + * + * @param tableKey The key of the table. + * @param firstRowIndex The index of the first row of the block. + * @param rowCount The number of rows in the block. + * @param delta The number of positions to move: -1 moves the block up, +1 moves it down. + */ + void moveRows(String tableKey, int firstRowIndex, int rowCount, int delta); + + /** + * Appends a pending column to a table; it has no values yet. + * + * @param tableKey The key of the table. + * @param columnName The name of the new column. + */ + void addColumn(String tableKey, String columnName); + + /** + * Renames a column of a table. + * + * @param tableKey The key of the table. + * @param columnName The current name of the column. + * @param newColumnName The new name of the column. + */ + void renameColumn(String tableKey, String columnName, String newColumnName); + + /** + * Deletes a column from a table. + * + * @param tableKey The key of the table. + * @param columnName The name of the column to delete. + */ + void deleteColumn(String tableKey, String columnName); + + /** + * Appends an empty table. + * + * @param tableName The name of the new table. + * @param columnNames The names of pending columns to add to the new table. + */ + void addTable(String tableName, List columnNames); + + /** + * Renames a table. + * + * @param tableKey The key of the table. + * @param newTableName The new name of the table. + */ + void renameTable(String tableKey, String newTableName); + + /** + * Deletes a table. + * + * @param tableKey The key of the table to delete. + */ + void deleteTable(String tableKey); + + /** + * Releases the resources held by this document. + */ + void dispose(); +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/DatasetModelChangeEvent.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/DatasetModelChangeEvent.java new file mode 100644 index 00000000..e8bf6748 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/DatasetModelChangeEvent.java @@ -0,0 +1,35 @@ +/* + * + * 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 org.dbunit.eclipse.dataset.core.model.DatasetModel; + +/** + * A dataset model change, delivered to every {@link DatasetModelListener}. + * + * @param oldModel The model before the change. + * @param newModel The model after the change. + * @param origin What caused the change. + * @since 1.0.0 + */ +public record DatasetModelChangeEvent(DatasetModel oldModel, DatasetModel newModel, ChangeOrigin origin) +{ +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/DatasetModelListener.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/DatasetModelListener.java new file mode 100644 index 00000000..e7fd7ce2 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/DatasetModelListener.java @@ -0,0 +1,37 @@ +/* + * + * 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; + +/** + * Notified when a {@link DatasetDocument}'s model changes. + * + * @since 1.0.0 + */ +@FunctionalInterface +public interface DatasetModelListener +{ + /** + * Called after the model has changed. + * + * @param event The change. + */ + void modelChanged(DatasetModelChangeEvent event); +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/TextDatasetDocument.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/TextDatasetDocument.java new file mode 100644 index 00000000..9265cddf --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/TextDatasetDocument.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.core.edit; + +import java.util.Optional; + +import org.dbunit.eclipse.dataset.core.model.CellAddress; +import org.eclipse.jface.text.IRegion; + +/** + * A dataset stored as text, supporting navigation between cells and text offsets. + * + * @since 1.0.0 + */ +public interface TextDatasetDocument extends DatasetDocument +{ + /** + * Returns whether the text has no content worth parsing. + * + * @return True when the text is empty or only whitespace. + */ + boolean isBlank(); + + /** + * Replaces a blank text with an empty dataset: an XML declaration, {@code }, and + * {@code }. + * + * @throws DatasetEditException When the text is not blank. + */ + void createEmptyDataset(); + + /** + * Locates a cell or a row in the text. + * + * @param address The cell to locate, or a row when its column index is -1. + * @return The attribute value range of the cell, the element name range when the cell is NULL, or + * the element range for a row address; empty when the address does not exist. + */ + Optional locate(CellAddress address); + + /** + * Finds the cell at a text offset. + * + * @param offset The offset to look up. + * @return The attribute under the offset, or the row's first column when the offset is not inside an + * attribute; empty when the offset is not inside a row element. + */ + Optional cellAt(int offset); +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java new file mode 100644 index 00000000..c23ec704 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java @@ -0,0 +1,601 @@ +/* + * + * 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.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Supplier; + +import org.dbunit.eclipse.dataset.core.dtd.DtdDeclarations; +import org.dbunit.eclipse.dataset.core.dtd.DtdReader; +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.DatasetModelChangeEvent; +import org.dbunit.eclipse.dataset.core.edit.DatasetModelListener; +import org.dbunit.eclipse.dataset.core.edit.TextDatasetDocument; +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.eclipse.jface.text.BadLocationException; +import org.eclipse.jface.text.DocumentEvent; +import org.eclipse.jface.text.DocumentRewriteSession; +import org.eclipse.jface.text.DocumentRewriteSessionType; +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.jface.text.Region; +import org.eclipse.jface.text.TextUtilities; +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; + +/** + * A dbUnit flat XML dataset bound to an Eclipse text document: the document is the single source of + * truth, and every edit becomes a minimal text edit applied to it. + * + * @since 1.0.0 + */ +public final class FlatXmlDatasetDocument implements TextDatasetDocument +{ + private static final int REWRITE_SESSION_EDIT_THRESHOLD = 50; + + private static final FlatXmlIndex EMPTY_INDEX = + new FlatXmlIndex(null, null, "", List.of(), Map.of(), Map.of(), Map.of()); + + private final IDocumentListener documentListener = new IDocumentListener() + { + @Override + public void documentAboutToBeChanged(final DocumentEvent event) + { + // Nothing to do before a change: documentChanged marks the model stale afterwards. + } + + @Override + public void documentChanged(final DocumentEvent event) + { + stale = true; + } + }; + + private final List listeners = new ArrayList<>(); + + private final Map dtdCache = new LinkedHashMap<>(); + + private final Map> pendingColumns = new LinkedHashMap<>(); + + private final Supplier charset; + + private IDocument document; + + private DtdSource dtdSource; + + private FlatXmlOptions options; + + private DatasetModel model; + + private FlatXmlIndex index; + + private boolean stale; + + private int batchDepth; + + /** + * Creates a dataset document bound to a text document. + * + * @param document The text document; its content is the single source of truth. + * @param dtdSource Loads external DTD files the document's DOCTYPE refers to. + * @param options The case-sensitivity and column-sensing options. + * @param charset Returns the document's current charset, consulted only when escaping a value that + * needs one; a null result or a thrown exception falls back to UTF-8. + */ + public FlatXmlDatasetDocument(final IDocument document, final DtdSource dtdSource, + final FlatXmlOptions options, final Supplier charset) + { + this.document = document; + this.dtdSource = dtdSource; + this.options = options; + this.charset = charset; + this.model = DatasetModel.EMPTY; + this.index = EMPTY_INDEX; + this.stale = true; + document.addDocumentListener(documentListener); + } + + @Override + public DatasetModel getModel() + { + return model; + } + + @Override + public boolean isStale() + { + return stale; + } + + @Override + public void refresh() + { + refreshInternal(ChangeOrigin.DOCUMENT); + } + + @Override + public void addModelListener(final DatasetModelListener listener) + { + listeners.add(listener); + } + + @Override + public void removeModelListener(final DatasetModelListener listener) + { + listeners.remove(listener); + } + + @Override + public void batch(final Runnable operations) + { + final IDocumentUndoManager undoManager = DocumentUndoManagerRegistry.getDocumentUndoManager(document); + final boolean outermost = batchDepth == 0; + batchDepth++; + final DocumentRewriteSession session = outermost ? startRewriteSession() : null; + if (outermost && undoManager != null) + { + undoManager.beginCompoundChange(); + } + try + { + operations.run(); + } + finally + { + batchDepth--; + if (batchDepth == 0) + { + if (undoManager != null) + { + undoManager.endCompoundChange(); + } + stopRewriteSession(session); + } + } + } + + @Override + public void setCells(final String tableKey, final List changes) + { + throw new UnsupportedOperationException("setCells is not implemented yet."); + } + + @Override + public void insertRows(final String tableKey, final int rowIndex, final List> rows) + { + throw new UnsupportedOperationException("insertRows is not implemented yet."); + } + + @Override + public void duplicateRows(final String tableKey, final int[] rowIndexes) + { + throw new UnsupportedOperationException("duplicateRows is not implemented yet."); + } + + @Override + public void deleteRows(final String tableKey, final int[] rowIndexes) + { + throw new UnsupportedOperationException("deleteRows is not implemented yet."); + } + + @Override + public void moveRows(final String tableKey, final int firstRowIndex, final int rowCount, + final int delta) + { + throw new UnsupportedOperationException("moveRows is not implemented yet."); + } + + @Override + public void addColumn(final String tableKey, final String columnName) + { + throw new UnsupportedOperationException("addColumn is not implemented yet."); + } + + @Override + public void renameColumn(final String tableKey, final String columnName, final String newColumnName) + { + throw new UnsupportedOperationException("renameColumn is not implemented yet."); + } + + @Override + public void deleteColumn(final String tableKey, final String columnName) + { + throw new UnsupportedOperationException("deleteColumn is not implemented yet."); + } + + @Override + public void addTable(final String tableName, final List columnNames) + { + throw new UnsupportedOperationException("addTable is not implemented yet."); + } + + @Override + public void renameTable(final String tableKey, final String newTableName) + { + throw new UnsupportedOperationException("renameTable is not implemented yet."); + } + + @Override + public void deleteTable(final String tableKey) + { + throw new UnsupportedOperationException("deleteTable is not implemented yet."); + } + + @Override + public void dispose() + { + document.removeDocumentListener(documentListener); + listeners.clear(); + } + + @Override + public boolean isBlank() + { + return document.get().isBlank(); + } + + @Override + public void createEmptyDataset() + { + if (!isBlank()) + { + throw new DatasetEditException("Cannot create an empty dataset because the document is not " + + "blank."); + } + final String delimiter = TextUtilities.getDefaultLineDelimiter(document); + final String newText = "" + delimiter + "" + + delimiter + "" + delimiter; + apply(List.of(new ReplaceEdit(0, document.getLength(), newText))); + refreshInternal(ChangeOrigin.EDIT); + } + + @Override + public Optional locate(final CellAddress address) + { + final Optional maybeTable = model.findTable(address.tableKey()); + if (maybeTable.isEmpty()) + { + return Optional.empty(); + } + final DatasetTable table = maybeTable.get(); + final List rowElements = index.getRowElements(address.tableKey()); + if (address.rowIndex() < 0 || address.rowIndex() >= rowElements.size()) + { + return Optional.empty(); + } + final FlatXmlElement element = rowElements.get(address.rowIndex()); + if (address.columnIndex() < 0) + { + return Optional.of(new Region(element.offset(), element.endOffset() - element.offset())); + } + if (address.columnIndex() >= table.getColumns().size()) + { + return Optional.empty(); + } + final String columnName = table.getColumns().get(address.columnIndex()).name(); + final FlatXmlAttribute attribute = findAttribute(element, columnName); + if (attribute == null) + { + return Optional.of(new Region(element.offset() + 1, + element.nameEndOffset() - element.offset() - 1)); + } + return Optional.of( + new Region(attribute.valueOffset(), attribute.valueEndOffset() - attribute.valueOffset())); + } + + @Override + public Optional cellAt(final int offset) + { + final FlatXmlElement element = findElementContaining(offset); + if (element == null) + { + return Optional.empty(); + } + final String key = tableKey(element.name()); + final List rowElements = index.getRowElements(key); + final int rowIndex = rowElements.indexOf(element); + if (rowIndex < 0) + { + return Optional.empty(); + } + final Optional table = model.findTable(key); + if (table.isEmpty()) + { + return Optional.empty(); + } + final int columnIndex = columnIndexAt(table.get(), element, offset); + return Optional.of(new CellAddress(key, rowIndex, columnIndex)); + } + + private static int columnIndexAt(final DatasetTable table, final FlatXmlElement element, final int offset) + { + int columnIndex = 0; + for (final FlatXmlAttribute attribute : element.attributes()) + { + if (offset >= attribute.nameOffset() && offset < attribute.endOffset()) + { + columnIndex = Math.max(0, table.getColumnIndex(attribute.name())); + } + } + return columnIndex; + } + + /** + * Changes the case-sensitivity and column-sensing options, then refreshes. + * + * @param newOptions The new options. + */ + public void setOptions(final FlatXmlOptions newOptions) + { + this.options = newOptions; + stale = true; + refresh(); + } + + /** + * Reads every cached DTD again through the {@link DtdSource}. When any text differs from the cached + * one, including a DTD that became readable or unreadable, replaces the cache, marks the model stale, + * and refreshes with {@link ChangeOrigin#DOCUMENT}. Does nothing when nothing changed. + */ + public void reloadDtd() + { + boolean changed = false; + for (final Map.Entry entry : new LinkedHashMap<>(dtdCache).entrySet()) + { + final String systemId = entry.getKey(); + final CachedDtd cached = entry.getValue(); + final String freshText = dtdSource.load(cached.publicId(), systemId).orElse(null); + if (!Objects.equals(freshText, cached.text())) + { + dtdCache.put(systemId, new CachedDtd(cached.publicId(), freshText)); + changed = true; + } + } + if (changed) + { + stale = true; + refresh(); + } + } + + /** + * Rebinds this document to a new text document and DTD source, typically after Save As, then marks + * the model stale and refreshes. + * + * @param newDocument The new text document. + * @param newDtdSource The new DTD source. + */ + public void rebind(final IDocument newDocument, final DtdSource newDtdSource) + { + document.removeDocumentListener(documentListener); + this.document = newDocument; + this.dtdSource = newDtdSource; + this.dtdCache.clear(); + document.addDocumentListener(documentListener); + stale = true; + refresh(); + } + + private void refreshInternal(final ChangeOrigin origin) + { + if (!stale) + { + return; + } + final String text = document.get(); + final FlatXmlParseResult parse = FlatXmlParser.parse(text); + final DtdResolution dtdResolution = resolveDtd(parse.doctype()); + final FlatXmlModelBuilder.Result built = FlatXmlModelBuilder.build(text, parse, + dtdResolution.declarations(), options, pendingColumns); + final List problems = FlatXmlValidator.validate(parse, built.index(), + built.model().getTables(), dtdResolution.state(), dtdResolution.declarations(), options); + final DatasetModel oldModel = model; + model = new DatasetModel(built.model().getTables(), problems, built.model().isEditable()); + index = built.index(); + stale = false; + notifyListeners(new DatasetModelChangeEvent(oldModel, model, origin)); + } + + private DtdResolution resolveDtd(final FlatXmlDoctype doctype) + { + if (doctype == null) + { + return new DtdResolution(null, DtdState.NONE); + } + final String internalSubsetText = doctype.internalSubset(); + DtdDeclarations internalSubset = + DtdReader.read(internalSubsetText == null ? "" : internalSubsetText); + if (internalSubsetText != null) + { + internalSubset = internalSubset.withProblemsShiftedBy(doctype.internalSubsetOffset()); + } + if (doctype.systemId() == null) + { + return new DtdResolution(internalSubset, DtdState.LOADED); + } + final String externalText = loadExternalDtd(doctype.publicId(), doctype.systemId()); + if (externalText == null) + { + return new DtdResolution(internalSubset, DtdState.NOT_LOADED); + } + final DtdDeclarations external = DtdReader.read(externalText) + .withProblemsAt(doctype.offset(), doctype.endOffset() - doctype.offset()); + return new DtdResolution(internalSubset.merge(external), DtdState.LOADED); + } + + private String loadExternalDtd(final String publicId, final String systemId) + { + final CachedDtd cached = dtdCache.get(systemId); + if (cached != null) + { + return cached.text(); + } + final String text = loadFromDtdSource(publicId, systemId); + dtdCache.put(systemId, new CachedDtd(publicId, text)); + return text; + } + + /** + * Loads from {@link #dtdSource}, treating a failure the same as a DTD it could not find, so that no + * {@link DtdSource} implementation can break the model by letting an unchecked exception escape. + */ + private String loadFromDtdSource(final String publicId, final String systemId) + { + try + { + return dtdSource.load(publicId, systemId).orElse(null); + } + catch (final RuntimeException e) + { + return null; + } + } + + private void apply(final List edits) + { + final MultiTextEdit root = new MultiTextEdit(); + for (final TextEdit edit : edits) + { + root.addChild(edit); + } + final boolean outermost = batchDepth == 0; + final boolean largeChange = edits.size() > REWRITE_SESSION_EDIT_THRESHOLD; + final DocumentRewriteSession session = outermost && largeChange ? startRewriteSession() : null; + final IDocumentUndoManager undoManager = + DocumentUndoManagerRegistry.getDocumentUndoManager(document); + if (outermost && undoManager != null) + { + undoManager.beginCompoundChange(); + } + try + { + root.apply(document, TextEdit.NONE); + } + catch (final MalformedTreeException | BadLocationException e) + { + throw new IllegalStateException("Computed text edits do not fit the document.", e); + } + finally + { + if (outermost && undoManager != null) + { + undoManager.endCompoundChange(); + } + stopRewriteSession(session); + } + } + + private DocumentRewriteSession startRewriteSession() + { + if (document instanceof final IDocumentExtension4 extension) + { + return extension.startRewriteSession(DocumentRewriteSessionType.UNRESTRICTED); + } + return null; + } + + private void stopRewriteSession(final DocumentRewriteSession session) + { + if (session != null) + { + ((IDocumentExtension4) document).stopRewriteSession(session); + } + } + + private void notifyListeners(final DatasetModelChangeEvent event) + { + for (final DatasetModelListener listener : new ArrayList<>(listeners)) + { + listener.modelChanged(event); + } + } + + private FlatXmlElement findElementContaining(final int offset) + { + final List elements = index.getElements(); + int low = 0; + int high = elements.size() - 1; + while (low <= high) + { + final int middle = (low + high) >>> 1; + final FlatXmlElement element = elements.get(middle); + if (offset < element.offset()) + { + high = middle - 1; + } + else if (offset >= element.endOffset()) + { + low = middle + 1; + } + else + { + return element; + } + } + return null; + } + + private static FlatXmlAttribute findAttribute(final FlatXmlElement element, final String columnName) + { + final String columnKey = columnName.toUpperCase(Locale.ENGLISH); + FlatXmlAttribute found = null; + for (final FlatXmlAttribute attribute : element.attributes()) + { + if (attribute.name().toUpperCase(Locale.ENGLISH).equals(columnKey)) + { + found = attribute; + } + } + return found; + } + + private String tableKey(final String name) + { + return options.caseSensitiveTableNames() ? name : name.toUpperCase(Locale.ENGLISH); + } + + private record DtdResolution(DtdDeclarations declarations, DtdState state) + { + } + + private record CachedDtd(String publicId, String text) + { + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlTextLayout.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlTextLayout.java new file mode 100644 index 00000000..6b18dd07 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlTextLayout.java @@ -0,0 +1,158 @@ +/* + * + * 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.util.List; + +import org.eclipse.jface.text.IRegion; +import org.eclipse.jface.text.Region; + +/** + * The layout facts the edit engine needs to keep new and moved text looking like the rest of the + * document: line delimiters, indentation, and whether an element is alone on its line. + */ +final class FlatXmlTextLayout +{ + private final String text; + + private final String lineDelimiter; + + FlatXmlTextLayout(final String text, final String lineDelimiter) + { + this.text = text; + this.lineDelimiter = lineDelimiter; + } + + String getLineDelimiter() + { + return lineDelimiter; + } + + /** + * Returns the whitespace between the start of the element's line and the element. + * + * @return That whitespace, or the empty string when non-whitespace precedes the element on its line. + */ + String indentOf(final FlatXmlElement element) + { + return leadingWhitespaceOf(element.offset()); + } + + /** + * Returns the indentation new children of the root should use. + * + * @param root The root element. + * @param children The root's current children, in document order. + * @return {@code indentOf} the first child, or, without children, the root line's indentation plus + * two spaces. + */ + String childIndentation(final FlatXmlRoot root, final List children) + { + if (!children.isEmpty()) + { + return indentOf(children.get(0)); + } + return leadingWhitespaceOf(root.offset()) + " "; + } + + /** + * Returns the range to remove so that deleting an element leaves no blank line behind. + * + * @return The whole line, including its delimiter, when only whitespace precedes the element on its + * line and only whitespace follows it there; otherwise the element alone. + */ + IRegion lineExtent(final FlatXmlElement element) + { + final int lineStart = lineStartOffset(element.offset()); + if (!isBlank(text, lineStart, element.offset())) + { + return elementRegion(element); + } + final int lineEnd = lineEndOffset(element.endOffset()); + if (!isBlank(text, element.endOffset(), lineEnd)) + { + return elementRegion(element); + } + final int delimiterEnd = lineEnd + delimiterLengthAt(lineEnd); + return new Region(lineStart, delimiterEnd - lineStart); + } + + private static IRegion elementRegion(final FlatXmlElement element) + { + return new Region(element.offset(), element.endOffset() - element.offset()); + } + + private String leadingWhitespaceOf(final int offset) + { + final int lineStart = lineStartOffset(offset); + return isBlank(text, lineStart, offset) ? text.substring(lineStart, offset) : ""; + } + + private int lineStartOffset(final int offset) + { + int index = offset; + while (index > 0 && !isLineBreakChar(text.charAt(index - 1))) + { + index--; + } + return index; + } + + private int lineEndOffset(final int offset) + { + int index = offset; + while (index < text.length() && !isLineBreakChar(text.charAt(index))) + { + index++; + } + return index; + } + + private int delimiterLengthAt(final int offset) + { + if (offset >= text.length()) + { + return 0; + } + if (text.charAt(offset) == '\r') + { + return offset + 1 < text.length() && text.charAt(offset + 1) == '\n' ? 2 : 1; + } + return isLineBreakChar(text.charAt(offset)) ? 1 : 0; + } + + private static boolean isLineBreakChar(final char ch) + { + return ch == '\n' || ch == '\r'; + } + + private static boolean isBlank(final String value, final int start, final int end) + { + for (int index = start; index < end; index++) + { + if (!Character.isWhitespace(value.charAt(index))) + { + return false; + } + } + return true; + } +} 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..5623698d --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocumentTest.java @@ -0,0 +1,407 @@ +/* + * + * 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.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +import org.dbunit.eclipse.dataset.core.TestDatasets; +import org.dbunit.eclipse.dataset.core.dtd.DtdSource; +import org.dbunit.eclipse.dataset.core.edit.ChangeOrigin; +import org.dbunit.eclipse.dataset.core.edit.DatasetEditException; +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.ProblemCode; +import org.eclipse.jface.text.Document; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.IRegion; +import org.eclipse.text.undo.DocumentUndoManagerRegistry; +import org.eclipse.text.undo.IDocumentUndoManager; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link FlatXmlDatasetDocument} against the staleness, refresh, and navigation rules of the Edit + * Engine specification, using a plain {@link Document} (no workbench needed). + */ +class FlatXmlDatasetDocumentTest +{ + @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 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_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 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_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_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 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"); + } + + private static FlatXmlDatasetDocument create(final IDocument document) + { + return new FlatXmlDatasetDocument(document, DtdSource.NONE, FlatXmlOptions.DBUNIT_DEFAULTS, + () -> StandardCharsets.UTF_8); + } + + 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); + } + } +} 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..5df56d56 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlTextLayoutTest.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.core.flatxml; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.eclipse.jface.text.IRegion; +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_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_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"); + } + + 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); + } +} From 843142ccd04ba3097bd169e9e574f6e5b2b68100 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Thu, 24 Sep 2026 08:22:05 -0500 Subject: [PATCH 008/170] feat(flatxml): Edit cell values with minimal attribute rewrites * Add StartTagRewriter.rewrite(text, element, columns, changes, renames, encoder), rebuilding one element's attributes segment by segment so every untouched attribute is copied byte for byte: a changed value keeps its own quote style, spacing, and position; a value set to null removes the attribute and its leading whitespace; a new value for a column the element lacks is inserted at that column's position, in a fresh double-quoted attribute. Returns null when the result equals the original text, so an unchanged value produces no edit. Built generically enough to also drive column renames later, since both share the same segment-by-segment rewrite. * Implement FlatXmlDatasetDocument.setCells: refreshes first and rejects a non-editable model, validates every row index and column name, groups changes by row, rejects a change that would leave a row with every column null, computes one ReplaceEdit per changed row element from fresh offsets, and applies them all through the existing apply()/batch() machinery, so several rows, or several setCells calls inside one batch, are one undo step. Escaping goes through AttributeValueCodec.escape with an encoder for the document's current charset, falling back to UTF-8. * Cover both with FlatXmlDatasetDocumentTest: double- and single-quoted values, removing a value, inserting a value at the first, a middle, and the last column position, one undo step for several changes to one element, for several rows, and for two setCells calls in one batch, undo still one step past the 50-edit rewrite-session threshold even with an IDocumentRewriteSessionListener that begins and ends a compound change the way a text viewer would, escaping of every special character, a no-op change adding no undo step, a multi-line start tag keeping its layout, comments and CR LF line ends untouched elsewhere, a character the charset cannot encode becoming a numeric reference, emptying a row rejected, a non-XML character rejected, and fresh offsets after a direct document.replace elsewhere. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr --- .../core/flatxml/FlatXmlDatasetDocument.java | 143 +++++- .../core/flatxml/StartTagRewriter.java | 163 +++++++ .../flatxml/FlatXmlDatasetDocumentTest.java | 409 ++++++++++++++++++ .../core/flatxml/StartTagRewriterTest.java | 54 +++ 4 files changed, 768 insertions(+), 1 deletion(-) create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/StartTagRewriter.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/StartTagRewriterTest.java diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java index c23ec704..e2452635 100644 --- a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java @@ -21,6 +21,8 @@ package org.dbunit.eclipse.dataset.core.flatxml; import java.nio.charset.Charset; +import java.nio.charset.CharsetEncoder; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -28,6 +30,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.function.Supplier; import org.dbunit.eclipse.dataset.core.dtd.DtdDeclarations; @@ -42,6 +45,7 @@ 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.DatasetRow; import org.dbunit.eclipse.dataset.core.model.DatasetTable; import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.DocumentEvent; @@ -194,7 +198,74 @@ public void batch(final Runnable operations) @Override public void setCells(final String tableKey, final List changes) { - throw new UnsupportedOperationException("setCells is not implemented yet."); + refresh(); + if (!getModel().isEditable()) + { + throw new DatasetEditException("Cannot edit because the source has errors that block " + + "editing."); + } + final DatasetTable table = getModel().findTable(tableKey) + .orElseThrow(() -> new DatasetEditException("There is no table '" + tableKey + "'.")); + for (final CellChange change : changes) + { + if (change.rowIndex() < 0 || change.rowIndex() >= table.getRows().size()) + { + throw new DatasetEditException( + "Row " + change.rowIndex() + " does not exist in table '" + table.getName() + + "'."); + } + if (table.getColumnIndex(change.columnName()) < 0) + { + throw new DatasetEditException("Column '" + change.columnName() + "' does not exist in " + + "table '" + table.getName() + "'."); + } + } + + final Map> changesByRow = new LinkedHashMap<>(); + for (final CellChange change : changes) + { + final String columnKey = change.columnName().toUpperCase(Locale.ENGLISH); + changesByRow.computeIfAbsent(change.rowIndex(), unused -> new LinkedHashMap<>()) + .put(columnKey, change.value()); + } + + final List rowElements = index.getRowElements(tableKey); + final CharsetEncoder encoder = currentEncoder(); + final List edits = new ArrayList<>(); + final String text = document.get(); + for (final Map.Entry> entry : changesByRow.entrySet()) + { + final int rowIndex = entry.getKey(); + final Map rowChanges = entry.getValue(); + final FlatXmlElement element = rowElements.get(rowIndex); + final String ambiguousKey = findCaseVariantColumnKey(element, rowChanges.keySet()); + if (ambiguousKey != null) + { + final String columnName = table.getColumns().get(table.getColumnIndex(ambiguousKey)).name(); + throw new DatasetEditException("Cannot change column '" + columnName + "' because row " + + rowIndex + " of table '" + table.getName() + "' has two attributes for it that " + + "differ only in letter case; remove one of them on the Source page first."); + } + if (wouldEmptyRow(table, rowIndex, rowChanges)) + { + throw new DatasetEditException( + "This change would leave row " + rowIndex + " of table '" + table.getName() + + "' with no values."); + } + final String rewritten = StartTagRewriter.rewrite(text, element, table.getColumns(), + rowChanges, Map.of(), encoder); + if (rewritten != null) + { + edits.add(new ReplaceEdit(element.nameEndOffset(), + element.attributesEndOffset() - element.nameEndOffset(), rewritten)); + } + } + if (edits.isEmpty()) + { + return; + } + apply(edits); + refreshInternal(ChangeOrigin.EDIT); } @Override @@ -591,6 +662,76 @@ private String tableKey(final String name) return options.caseSensitiveTableNames() ? name : name.toUpperCase(Locale.ENGLISH); } + /** + * Returns whether applying rowChanges (column key to new value) to a row would leave every column + * NULL; a row with no values would leave its element without attributes, which is not a valid row. + */ + private static boolean wouldEmptyRow(final DatasetTable table, final int rowIndex, + final Map rowChanges) + { + final DatasetRow row = table.getRows().get(rowIndex); + for (int columnIndex = 0; columnIndex < table.getColumns().size(); columnIndex++) + { + final String columnKey = table.getColumns().get(columnIndex).name().toUpperCase(Locale.ENGLISH); + final String value = rowChanges.containsKey(columnKey) ? rowChanges.get(columnKey) + : row.getValue(columnIndex); + if (value != null) + { + return false; + } + } + return true; + } + + /** + * Returns the first key in columnKeys whose element has two or more attributes that differ only in + * letter case, or null when none do. + */ + private static String findCaseVariantColumnKey(final FlatXmlElement element, final Set columnKeys) + { + for (final String columnKey : columnKeys) + { + if (hasCaseVariantAttributes(element, columnKey)) + { + return columnKey; + } + } + return null; + } + + /** + * Returns whether element has two or more attributes whose upper-cased name equals columnKey. + */ + private static boolean hasCaseVariantAttributes(final FlatXmlElement element, final String columnKey) + { + int matches = 0; + for (final FlatXmlAttribute attribute : element.attributes()) + { + if (attribute.name().toUpperCase(Locale.ENGLISH).equals(columnKey)) + { + matches++; + } + } + return matches > 1; + } + + /** + * Returns an encoder for the document's current charset, falling back to UTF-8 when the supplier + * returns null or throws. + */ + private CharsetEncoder currentEncoder() + { + try + { + final Charset result = charset.get(); + return (result != null ? result : StandardCharsets.UTF_8).newEncoder(); + } + catch (final RuntimeException e) + { + return StandardCharsets.UTF_8.newEncoder(); + } + } + private record DtdResolution(DtdDeclarations declarations, DtdState state) { } diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/StartTagRewriter.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/StartTagRewriter.java new file mode 100644 index 00000000..d25235c8 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/StartTagRewriter.java @@ -0,0 +1,163 @@ +/* + * + * 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.CharsetEncoder; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; + +/** + * Rewrites one element's start tag: changed attributes and renames are applied segment by segment, so + * every untouched attribute is copied byte for byte, preserving its quote style, spacing, and position. + */ +final class StartTagRewriter +{ + private StartTagRewriter() + { + } + + /** + * Computes the new text of an element's attributes. + * + * @param text The document text the element was parsed from. + * @param element The element to rewrite. + * @param columns The table's columns, used for ordering new attributes and for their display names. + * @param changes Column key (upper-cased) to new value; null means NULL (the attribute is removed). + * A column key absent from this map is unchanged. The caller must not change a column + * for which element has two or more attributes differing only in letter case, because + * every one of them matches the same key and would receive the same change. + * @param renames Column key (upper-cased) to new column name. A column key absent from this map + * keeps its spelling. + * @param encoder The encoder used to decide which characters need a numeric character reference. + * @return The replacement text for {@code text[element.nameEndOffset(), element.attributesEndOffset())}, + * or null when the result equals the original text. + */ + static String rewrite(final String text, final FlatXmlElement element, final List columns, + final Map changes, final Map renames, + final CharsetEncoder encoder) + { + final Map columnIndexByKey = new HashMap<>(); + final Map displayNameByKey = new HashMap<>(); + for (int index = 0; index < columns.size(); index++) + { + final String key = columns.get(index).name().toUpperCase(Locale.ENGLISH); + columnIndexByKey.put(key, index); + displayNameByKey.put(key, columns.get(index).name()); + } + + final Set existingKeys = existingKeys(element); + final List additions = pendingAdditions(changes, existingKeys, columnIndexByKey); + + final StringBuilder result = new StringBuilder(); + int nextAddition = 0; + for (final FlatXmlAttribute attribute : element.attributes()) + { + final String key = attribute.name().toUpperCase(Locale.ENGLISH); + final int attributeColumnIndex = columnIndexByKey.getOrDefault(key, Integer.MAX_VALUE); + while (nextAddition < additions.size() && columnIndexByKey + .getOrDefault(additions.get(nextAddition), Integer.MAX_VALUE) < attributeColumnIndex) + { + appendAddition(result, additions.get(nextAddition), changes, displayNameByKey, encoder); + nextAddition++; + } + appendAttribute(result, text, attribute, key, changes, renames, encoder); + } + while (nextAddition < additions.size()) + { + appendAddition(result, additions.get(nextAddition), changes, displayNameByKey, encoder); + nextAddition++; + } + + final String rewritten = result.toString(); + final String original = text.substring(element.nameEndOffset(), element.attributesEndOffset()); + return rewritten.equals(original) ? null : rewritten; + } + + private static Set existingKeys(final FlatXmlElement element) + { + final Set keys = new HashSet<>(); + for (final FlatXmlAttribute attribute : element.attributes()) + { + keys.add(attribute.name().toUpperCase(Locale.ENGLISH)); + } + return keys; + } + + private static List pendingAdditions(final Map changes, + final Set existingKeys, final Map columnIndexByKey) + { + final List additions = new ArrayList<>(); + for (final Map.Entry entry : changes.entrySet()) + { + if (!existingKeys.contains(entry.getKey()) && entry.getValue() != null) + { + additions.add(entry.getKey()); + } + } + additions.sort(Comparator.comparingInt(key -> columnIndexByKey.getOrDefault(key, Integer.MAX_VALUE))); + return additions; + } + + private static void appendAttribute(final StringBuilder result, final String text, + final FlatXmlAttribute attribute, final String key, final Map changes, + final Map renames, final CharsetEncoder encoder) + { + final boolean hasChange = changes.containsKey(key); + final String rename = renames.get(key); + if (!hasChange && rename == null) + { + result.append(text, attribute.segmentOffset(), attribute.endOffset()); + return; + } + if (hasChange && changes.get(key) == null) + { + return; + } + result.append(text, attribute.segmentOffset(), attribute.nameOffset()); + result.append(rename != null ? rename : attribute.name()); + result.append(text, attribute.nameOffset() + attribute.name().length(), attribute.valueOffset()); + if (hasChange) + { + result.append(AttributeValueCodec.escape(changes.get(key), encoder)); + } + else + { + result.append(text, attribute.valueOffset(), attribute.valueEndOffset()); + } + result.append(attribute.quote()); + } + + private static void appendAddition(final StringBuilder result, final String key, + final Map changes, final Map displayNameByKey, + final CharsetEncoder encoder) + { + result.append(' ').append(displayNameByKey.getOrDefault(key, key)).append("=\"") + .append(AttributeValueCodec.escape(changes.get(key), encoder)).append('"'); + } +} 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 index 5623698d..deba72c0 100644 --- 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 @@ -31,6 +31,7 @@ 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.model.CellAddress; @@ -39,7 +40,9 @@ import org.dbunit.eclipse.dataset.core.model.DatasetTable; import org.dbunit.eclipse.dataset.core.model.ProblemCode; import org.eclipse.jface.text.Document; +import org.eclipse.jface.text.DocumentRewriteSessionEvent; import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.IDocumentExtension4; import org.eclipse.jface.text.IRegion; import org.eclipse.text.undo.DocumentUndoManagerRegistry; import org.eclipse.text.undo.IDocumentUndoManager; @@ -378,6 +381,406 @@ void testLocate_whenCellIsNull_returnsTheElementNameRange() 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 testBatch_whenHoldingTwoSetCellsCalls_isOneUndoStep() throws Exception + { + final IDocument document = new Document( + ""); + final String original = document.get(); + withUndoManager(document, undoManager -> + { + final FlatXmlDatasetDocument datasetDocument = create(document); + datasetDocument.refresh(); + + datasetDocument.batch(() -> + { + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", "Alicia"))); + datasetDocument.setCells("USERS", List.of(new CellChange(1, "NAME", "Robert"))); + }); + + assertThat(document.get()).as("Both calls inside the batch must be applied.").isEqualTo( + ""); + undoManager.undo(); + assertThat(document.get()).as("One undo must revert the whole batch.").isEqualTo(original); + }); + } + + @Test + void testBatch_whenHoldingASetCellsOfMoreThan50RowsAndAnotherSetCells_isStillOneUndoStep() + throws Exception + { + final StringBuilder xml = new StringBuilder(""); + for (int i = 0; i < 60; i++) + { + xml.append(""); + } + xml.append(""); + final IDocument document = new Document(xml.toString()); + final String original = document.get(); + + withUndoManager(document, undoManager -> + { + // TextViewer begins a compound change when a rewrite session starts and ends it when the + // session stops; simulate that here to prove a session started inside the batch would not + // split the batch into more than one undo step (none must start, since apply() only starts a + // session when it is the outermost change). + ((IDocumentExtension4) document).addDocumentRewriteSessionListener(event -> + { + if (event.getChangeType() == DocumentRewriteSessionEvent.SESSION_START) + { + undoManager.beginCompoundChange(); + } + else if (event.getChangeType() == DocumentRewriteSessionEvent.SESSION_STOP) + { + undoManager.endCompoundChange(); + } + }); + + 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.batch(() -> + { + datasetDocument.setCells("USERS", manyChanges); + datasetDocument.setCells("ORDERS", List.of(new CellChange(0, "TOTAL", "9"))); + }); + + assertThat(document.get()).as("All changes must be applied.").contains("Changed59") + .contains("TOTAL=\"9\""); + undoManager.undo(); + assertThat(document.get()).as( + "Even past the 50-edit threshold, a batch's changes must still be one undo step.") + .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_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(""); + } + + 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, @@ -404,4 +807,10 @@ public Optional load(final String publicId, final String systemId) return Optional.of(text); } } + + @FunctionalInterface + private interface UndoManagerConsumer + { + void accept(IDocumentUndoManager undoManager) throws Exception; + } } diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/StartTagRewriterTest.java b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/StartTagRewriterTest.java new file mode 100644 index 00000000..7c4a67aa --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/StartTagRewriterTest.java @@ -0,0 +1,54 @@ +/* + * + * 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.StandardCharsets; +import java.util.List; +import java.util.Map; + +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link StartTagRewriter}. + */ +class StartTagRewriterTest +{ + @Test + void testRewrite_whenAnAddedAttributesColumnIsNotInColumns_sortsItLastInsteadOfThrowing() + { + final String text = ""; + final FlatXmlElement element = FlatXmlParser.parse(text).elements().get(0); + final List columns = List.of(new DatasetColumn("ID", false, true, false), + new DatasetColumn("NAME", false, true, false)); + final Map changes = Map.of("CITY", "Springfield"); + + final String rewritten = StartTagRewriter.rewrite(text, element, columns, changes, Map.of(), + StandardCharsets.UTF_8.newEncoder()); + + assertThat(rewritten) + .as("An added attribute whose column is absent from columnIndexByKey must sort after " + + "every known column instead of throwing a NullPointerException.") + .isEqualTo(" ID=\"1\" NAME=\"Alice\" CITY=\"Springfield\""); + } +} From 3d14505cda19636f383497b302948f22b1621e64 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Thu, 24 Sep 2026 11:31:54 -0500 Subject: [PATCH 009/170] feat(flatxml): Insert, duplicate, delete, and move rows * Add insertRows, duplicateRows, deleteRows, and moveRows to FlatXmlDatasetDocument, replacing their unimplemented stubs. * Insert copies the indentation of its anchor row, replaces a table's marker element when it has no rows yet, and appends a new element before the root's end tag for a table with no elements at all; an all-null new row gets the empty string in its first column. * Delete removes a row's whole line when it is alone there, removes only the element when inline with others, and always leaves a marker element behind so the table stays representable. * Duplicate copies the selected rows' text verbatim after the last selected row, in their original order. * Move rotates the text of a contiguous block of rows with its neighbor by one position, independent of other tables interleaved between them in the document. * Add FlatXmlTextLayout.startOfLineContaining and isAtStartOfItsLine, used to place a new last child before the root's end tag. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr --- .../core/flatxml/FlatXmlDatasetDocument.java | 302 +++++++++++- .../core/flatxml/FlatXmlTextLayout.java | 16 + .../flatxml/FlatXmlDatasetDocumentTest.java | 438 ++++++++++++++++++ 3 files changed, 752 insertions(+), 4 deletions(-) diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java index e2452635..f5421990 100644 --- a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java @@ -24,6 +24,7 @@ import java.nio.charset.CharsetEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -57,6 +58,8 @@ import org.eclipse.jface.text.IRegion; import org.eclipse.jface.text.Region; import org.eclipse.jface.text.TextUtilities; +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; @@ -110,6 +113,8 @@ public void documentChanged(final DocumentEvent event) private FlatXmlIndex index; + private FlatXmlTextLayout layout; + private boolean stale; private int batchDepth; @@ -271,26 +276,252 @@ public void setCells(final String tableKey, final List changes) @Override public void insertRows(final String tableKey, final int rowIndex, final List> rows) { - throw new UnsupportedOperationException("insertRows is not implemented yet."); + refresh(); + if (!getModel().isEditable()) + { + throw new DatasetEditException("Cannot edit because the source has errors that block " + + "editing."); + } + final DatasetTable table = getModel().findTable(tableKey) + .orElseThrow(() -> new DatasetEditException("There is no table '" + tableKey + "'.")); + if (table.getColumns().isEmpty()) + { + throw new DatasetEditException( + "Table '" + table.getName() + "' has no columns to insert a row into."); + } + final List rowElements = index.getRowElements(tableKey); + if (rowIndex < 0 || rowIndex > rowElements.size()) + { + throw new DatasetEditException( + "Row " + rowIndex + " is out of range for table '" + table.getName() + "'."); + } + for (final List values : rows) + { + if (values.size() != table.getColumns().size()) + { + throw new DatasetEditException("Each new row must have exactly " + + table.getColumns().size() + " values for table '" + table.getName() + "'."); + } + if (isAllNull(values)) + { + throw new DatasetEditException( + NLS.bind(Messages.Edit_newRowWouldBeEmpty, table.getName())); + } + } + if (rows.isEmpty()) + { + return; + } + + final CharsetEncoder encoder = currentEncoder(); + final String delimiter = layout.getLineDelimiter(); + final List rowTexts = new ArrayList<>(); + for (final List values : rows) + { + rowTexts.add(buildRowText(table, values, encoder)); + } + + final TextEdit edit; + if (rowIndex < rowElements.size()) + { + final FlatXmlElement anchor = rowElements.get(rowIndex); + final String indent = layout.indentOf(anchor); + final StringBuilder insertText = new StringBuilder(); + for (final String rowText : rowTexts) + { + insertText.append(rowText).append(delimiter).append(indent); + } + edit = new InsertEdit(anchor.offset(), insertText.toString()); + } + else if (!rowElements.isEmpty()) + { + final FlatXmlElement last = rowElements.get(rowElements.size() - 1); + final String indent = layout.indentOf(last); + final StringBuilder insertText = new StringBuilder(); + for (final String rowText : rowTexts) + { + insertText.append(delimiter).append(indent).append(rowText); + } + edit = new InsertEdit(last.endOffset(), insertText.toString()); + } + else if (!index.getMarkerElements(tableKey).isEmpty()) + { + final FlatXmlElement marker = index.getMarkerElements(tableKey).get(0); + final String indent = layout.indentOf(marker); + final String joined = String.join(delimiter + indent, rowTexts); + edit = new ReplaceEdit(marker.offset(), marker.endOffset() - marker.offset(), joined); + } + else + { + final String indent = layout.childIndentation(index.getRoot(), index.getElements()); + final String joined = String.join(delimiter + indent, rowTexts); + edit = insertAsLastChildOfRoot(joined); + } + + apply(List.of(edit)); + refreshInternal(ChangeOrigin.EDIT); } @Override public void duplicateRows(final String tableKey, final int[] rowIndexes) { - throw new UnsupportedOperationException("duplicateRows is not implemented yet."); + refresh(); + if (!getModel().isEditable()) + { + throw new DatasetEditException("Cannot edit because the source has errors that block " + + "editing."); + } + final DatasetTable table = getModel().findTable(tableKey) + .orElseThrow(() -> new DatasetEditException("There is no table '" + tableKey + "'.")); + final List rowElements = index.getRowElements(tableKey); + final int[] sorted = rowIndexes.clone(); + Arrays.sort(sorted); + for (final int rowIndex : sorted) + { + if (rowIndex < 0 || rowIndex >= rowElements.size()) + { + throw new DatasetEditException( + "Row " + rowIndex + " is out of range for table '" + table.getName() + "'."); + } + } + if (sorted.length == 0) + { + return; + } + + final FlatXmlElement lastSelected = rowElements.get(sorted[sorted.length - 1]); + final String delimiter = layout.getLineDelimiter(); + final String indent = layout.indentOf(lastSelected); + final String text = document.get(); + final StringBuilder insertText = new StringBuilder(); + for (final int rowIndex : sorted) + { + final FlatXmlElement element = rowElements.get(rowIndex); + insertText.append(delimiter).append(indent).append(text, element.offset(), + element.endOffset()); + } + + apply(List.of(new InsertEdit(lastSelected.endOffset(), insertText.toString()))); + refreshInternal(ChangeOrigin.EDIT); } @Override public void deleteRows(final String tableKey, final int[] rowIndexes) { - throw new UnsupportedOperationException("deleteRows is not implemented yet."); + refresh(); + if (!getModel().isEditable()) + { + throw new DatasetEditException("Cannot edit because the source has errors that block " + + "editing."); + } + final DatasetTable table = getModel().findTable(tableKey) + .orElseThrow(() -> new DatasetEditException("There is no table '" + tableKey + "'.")); + final List rowElements = index.getRowElements(tableKey); + final int[] sorted = rowIndexes.clone(); + Arrays.sort(sorted); + for (final int rowIndex : sorted) + { + if (rowIndex < 0 || rowIndex >= rowElements.size()) + { + throw new DatasetEditException( + "Row " + rowIndex + " is out of range for table '" + table.getName() + "'."); + } + } + if (sorted.length == 0) + { + return; + } + + final boolean deletingAllRows = sorted.length == rowElements.size(); + final boolean hasMarker = !index.getMarkerElements(tableKey).isEmpty(); + final List edits = new ArrayList<>(); + for (int position = 0; position < sorted.length; position++) + { + final FlatXmlElement element = rowElements.get(sorted[position]); + if (position == 0 && deletingAllRows && !hasMarker) + { + edits.add(new ReplaceEdit(element.offset(), element.endOffset() - element.offset(), + "<" + table.getName() + "/>")); + } + else + { + final IRegion region = layout.lineExtent(element); + edits.add(new DeleteEdit(region.getOffset(), region.getLength())); + } + } + + apply(edits); + refreshInternal(ChangeOrigin.EDIT); } @Override public void moveRows(final String tableKey, final int firstRowIndex, final int rowCount, final int delta) { - throw new UnsupportedOperationException("moveRows is not implemented yet."); + refresh(); + if (!getModel().isEditable()) + { + throw new DatasetEditException("Cannot edit because the source has errors that block " + + "editing."); + } + final DatasetTable table = getModel().findTable(tableKey) + .orElseThrow(() -> new DatasetEditException("There is no table '" + tableKey + "'.")); + final List rowElements = index.getRowElements(tableKey); + if (firstRowIndex < 0 || rowCount < 1 || firstRowIndex + rowCount > rowElements.size()) + { + throw new DatasetEditException( + "The row block is out of range for table '" + table.getName() + "'."); + } + if (delta != -1 && delta != 1) + { + throw new DatasetEditException("A row block can only move up or down by one position at a " + + "time."); + } + if (delta < 0 && firstRowIndex == 0) + { + throw new DatasetEditException( + "Cannot move past the first row of table '" + table.getName() + "'."); + } + if (delta > 0 && firstRowIndex + rowCount >= rowElements.size()) + { + throw new DatasetEditException( + "Cannot move past the last row of table '" + table.getName() + "'."); + } + + apply(rowMoveEdits(rowElements, firstRowIndex, rowCount, delta)); + refreshInternal(ChangeOrigin.EDIT); + } + + /** + * Returns the edits that move a block of rows one position up or down: one replace edit for each row of + * the block and for the neighbouring row it passes, which rotates their texts by one position. The + * arguments are already validated. + */ + private List rowMoveEdits(final List rowElements, final int firstRowIndex, + final int rowCount, final int delta) + { + final int startPosition = delta < 0 ? firstRowIndex - 1 : firstRowIndex; + final int positionCount = rowCount + 1; + final String text = document.get(); + final List positions = new ArrayList<>(); + final List texts = new ArrayList<>(); + for (int i = 0; i < positionCount; i++) + { + final FlatXmlElement element = rowElements.get(startPosition + i); + positions.add(element); + texts.add(text.substring(element.offset(), element.endOffset())); + } + + final List edits = new ArrayList<>(); + for (int i = 0; i < positionCount; i++) + { + final int sourceIndex = + delta > 0 ? Math.floorMod(i - 1, positionCount) : Math.floorMod(i + 1, positionCount); + final FlatXmlElement position = positions.get(i); + edits.add(new ReplaceEdit(position.offset(), position.endOffset() - position.offset(), + texts.get(sourceIndex))); + } + return edits; } @Override @@ -500,6 +731,7 @@ private void refreshInternal(final ChangeOrigin origin) final DatasetModel oldModel = model; model = new DatasetModel(built.model().getTables(), problems, built.model().isEditable()); index = built.index(); + layout = new FlatXmlTextLayout(text, TextUtilities.getDefaultLineDelimiter(document)); stale = false; notifyListeners(new DatasetModelChangeEvent(oldModel, model, origin)); } @@ -732,6 +964,68 @@ private CharsetEncoder currentEncoder() } } + /** + * Returns the text of a new row element: the table's display name, one attribute per non-null value + * in table column order, and a self-closing end. + */ + private static String buildRowText(final DatasetTable table, final List values, + final CharsetEncoder encoder) + { + final StringBuilder text = new StringBuilder(); + text.append('<').append(table.getName()); + for (int columnIndex = 0; columnIndex < table.getColumns().size(); columnIndex++) + { + final String value = values.get(columnIndex); + if (value != null) + { + text.append(' ').append(table.getColumns().get(columnIndex).name()).append("=\"") + .append(AttributeValueCodec.escape(value, encoder)).append('"'); + } + } + text.append("/>"); + return text.toString(); + } + + /** + * Returns whether every value is null; a new row with no values would have an element without + * attributes, which is not a valid row. + */ + private static boolean isAllNull(final List values) + { + for (final String value : values) + { + if (value != null) + { + return false; + } + } + return true; + } + + /** + * Inserts text as the last child of the root: before the root's end tag (at the start of its line + * when only whitespace precedes it there), or, when the root is self-closing, by replacing it with an + * open and close tag around the new text. + */ + private TextEdit insertAsLastChildOfRoot(final String childrenText) + { + final FlatXmlRoot root = index.getRoot(); + final String delimiter = layout.getLineDelimiter(); + final String childIndentation = layout.childIndentation(root, index.getElements()); + if (root.selfClosing()) + { + final String replacement = + ">" + delimiter + childIndentation + childrenText + delimiter + ""; + return new ReplaceEdit(root.startTagEndOffset() - 2, 2, replacement); + } + if (layout.isAtStartOfItsLine(root.endTagOffset())) + { + final int lineStart = layout.startOfLineContaining(root.endTagOffset()); + return new InsertEdit(lineStart, childIndentation + childrenText + delimiter); + } + return new InsertEdit(root.endTagOffset(), delimiter + childIndentation + childrenText + delimiter); + } + private record DtdResolution(DtdDeclarations declarations, DtdState state) { } diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlTextLayout.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlTextLayout.java index 6b18dd07..d3dcf327 100644 --- a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlTextLayout.java +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlTextLayout.java @@ -95,6 +95,22 @@ IRegion lineExtent(final FlatXmlElement element) return new Region(lineStart, delimiterEnd - lineStart); } + /** + * Returns the start of the line containing a position. + */ + int startOfLineContaining(final int offset) + { + return lineStartOffset(offset); + } + + /** + * Returns whether only whitespace precedes a position on its line. + */ + boolean isAtStartOfItsLine(final int offset) + { + return isBlank(text, lineStartOffset(offset), offset); + } + private static IRegion elementRegion(final FlatXmlElement element) { return new Region(element.offset(), element.endOffset() - element.offset()); 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 index deba72c0..00224fe5 100644 --- 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 @@ -25,6 +25,7 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; @@ -764,6 +765,443 @@ void testSetCells_afterADirectDocumentReplaceElsewhere_usesFreshOffsets() throws .isEqualTo(""); } + @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 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_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 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.") + .isInstanceOf(DatasetEditException.class); + assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); + } + + @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_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); + } + private static void withUndoManager(final IDocument document, final UndoManagerConsumer consumer) throws Exception { From 8d920a638bffbb2a3e842e69e430ea7cc2fbc200 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Thu, 24 Sep 2026 11:44:12 -0500 Subject: [PATCH 010/170] feat(flatxml): Add, rename, and delete columns * Add addColumn, renameColumn, and deleteColumn to FlatXmlDatasetDocument, replacing their unimplemented stubs. * Add records a pending column with no text change, rejecting an invalid name or one that already exists case-insensitively; a pending column is dropped automatically once a refresh finds it backed by real data or the DTD. * Rename rewrites every attribute matching the column, case- insensitively, across every row, or renames the entry in place for a still-pending column; it rejects an invalid or existing new name, and rejects the rename entirely, without changing anything, when a row has two attributes for the column that differ only in case. * Delete removes the attribute from every row that has it, or drops a pending column's entry with no text change; it rejects deleting a row's only value without changing anything, and leaves a DTD-declared column visible in the model even once no row has a value for it. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr --- .../META-INF/MANIFEST.MF | 3 +- .../core/flatxml/FlatXmlDatasetDocument.java | 414 ++++++++++++++++- .../flatxml/FlatXmlDatasetDocumentTest.java | 417 ++++++++++++++++++ 3 files changed, 830 insertions(+), 4 deletions(-) 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 ccabf57c..16938f42 100644 --- a/bundles/org.dbunit.eclipse.dataset.core/META-INF/MANIFEST.MF +++ b/bundles/org.dbunit.eclipse.dataset.core/META-INF/MANIFEST.MF @@ -10,7 +10,8 @@ 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" + org.eclipse.text;bundle-version="3.14.0", + org.eclipse.core.commands;bundle-version="3.12.0" Import-Package: org.eclipse.osgi.util;version="1.1.0" Export-Package: org.dbunit.eclipse.dataset.core, org.dbunit.eclipse.dataset.core.model, diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java index f5421990..604718fd 100644 --- a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java @@ -25,6 +25,8 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashSet; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -44,10 +46,19 @@ import org.dbunit.eclipse.dataset.core.edit.DatasetModelListener; import org.dbunit.eclipse.dataset.core.edit.TextDatasetDocument; 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.DatasetRow; import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.eclipse.core.commands.ExecutionException; +import org.eclipse.core.commands.operations.AbstractOperation; +import org.eclipse.core.commands.operations.IOperationHistory; +import org.eclipse.core.commands.operations.OperationHistoryFactory; +import org.eclipse.core.runtime.IAdaptable; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.core.runtime.IStatus; +import org.eclipse.core.runtime.Status; import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.DocumentEvent; import org.eclipse.jface.text.DocumentRewriteSession; @@ -77,6 +88,8 @@ public final class FlatXmlDatasetDocument implements TextDatasetDocument { private static final int REWRITE_SESSION_EDIT_THRESHOLD = 50; + private static final int PENDING_COLUMNS_HISTORY_LIMIT = 200; + private static final FlatXmlIndex EMPTY_INDEX = new FlatXmlIndex(null, null, "", List.of(), Map.of(), Map.of(), Map.of()); @@ -101,6 +114,8 @@ public void documentChanged(final DocumentEvent event) private final Map> pendingColumns = new LinkedHashMap<>(); + private final Map>> pendingColumnsHistory = new LinkedHashMap<>(); + private final Supplier charset; private IDocument document; @@ -119,6 +134,8 @@ public void documentChanged(final DocumentEvent event) private int batchDepth; + private long lastRefreshModificationStamp = IDocumentExtension4.UNKNOWN_MODIFICATION_STAMP; + /** * Creates a dataset document bound to a text document. * @@ -527,19 +544,314 @@ private List rowMoveEdits(final List rowElements, fina @Override public void addColumn(final String tableKey, final String columnName) { - throw new UnsupportedOperationException("addColumn is not implemented yet."); + refresh(); + if (!getModel().isEditable()) + { + throw new DatasetEditException("Cannot edit because the source has errors that block " + + "editing."); + } + final DatasetTable table = getModel().findTable(tableKey) + .orElseThrow(() -> new DatasetEditException("There is no table '" + tableKey + "'.")); + if (!XmlNames.isValidName(columnName)) + { + throw new DatasetEditException("'" + columnName + "' is not a valid column name."); + } + if (table.getColumnIndex(columnName) >= 0) + { + throw new DatasetEditException( + "Table '" + table.getName() + "' already has a column named '" + columnName + "'."); + } + + applyPendingColumnsChange("Add pending column", + () -> pendingColumns.computeIfAbsent(tableKey, unused -> new ArrayList<>()).add(columnName), + () -> removePendingColumn(tableKey, columnName)); } @Override public void renameColumn(final String tableKey, final String columnName, final String newColumnName) { - throw new UnsupportedOperationException("renameColumn is not implemented yet."); + refresh(); + if (!getModel().isEditable()) + { + throw new DatasetEditException("Cannot edit because the source has errors that block " + + "editing."); + } + final DatasetTable table = getModel().findTable(tableKey) + .orElseThrow(() -> new DatasetEditException("There is no table '" + tableKey + "'.")); + final int columnIndex = table.getColumnIndex(columnName); + if (columnIndex < 0) + { + throw new DatasetEditException( + "Column '" + columnName + "' does not exist in table '" + table.getName() + "'."); + } + if (!XmlNames.isValidName(newColumnName)) + { + throw new DatasetEditException("'" + newColumnName + "' is not a valid column name."); + } + final int conflictingIndex = table.getColumnIndex(newColumnName); + if (conflictingIndex >= 0 && conflictingIndex != columnIndex) + { + throw new DatasetEditException("Table '" + table.getName() + "' already has a column named '" + + newColumnName + "'."); + } + + final DatasetColumn column = table.getColumns().get(columnIndex); + final String key = columnName.toUpperCase(Locale.ENGLISH); + final List rowElements = index.getRowElements(tableKey); + for (final FlatXmlElement element : rowElements) + { + int matches = 0; + for (final FlatXmlAttribute attribute : element.attributes()) + { + if (attribute.name().toUpperCase(Locale.ENGLISH).equals(key)) + { + matches++; + } + } + if (matches > 1) + { + throw new DatasetEditException("Cannot rename column '" + column.name() + "' in table '" + + table.getName() + "' because a row has two attributes for it that differ only " + + "in letter case; remove one of them on the Source page first."); + } + } + + if (column.pending()) + { + applyPendingColumnsChange("Rename pending column", + () -> renamePendingColumn(tableKey, columnName, newColumnName), + () -> renamePendingColumn(tableKey, newColumnName, columnName)); + return; + } + if (column.declared() && !column.hasValues()) + { + throw new DatasetEditException("Cannot rename column '" + column.name() + "' in table '" + + table.getName() + "' because the DTD declares it but no row has a value for it; " + + "change the DTD instead."); + } + + final Map renames = new LinkedHashMap<>(); + renames.put(key, newColumnName); + final CharsetEncoder encoder = currentEncoder(); + final String text = document.get(); + final List edits = new ArrayList<>(); + for (final FlatXmlElement element : rowElements) + { + final String rewritten = + StartTagRewriter.rewrite(text, element, table.getColumns(), Map.of(), renames, encoder); + if (rewritten != null) + { + edits.add(new ReplaceEdit(element.nameEndOffset(), + element.attributesEndOffset() - element.nameEndOffset(), rewritten)); + } + } + if (edits.isEmpty()) + { + return; + } + apply(edits); + refreshInternal(ChangeOrigin.EDIT); } @Override public void deleteColumn(final String tableKey, final String columnName) { - throw new UnsupportedOperationException("deleteColumn is not implemented yet."); + refresh(); + if (!getModel().isEditable()) + { + throw new DatasetEditException("Cannot edit because the source has errors that block " + + "editing."); + } + final DatasetTable table = getModel().findTable(tableKey) + .orElseThrow(() -> new DatasetEditException("There is no table '" + tableKey + "'.")); + final int columnIndex = table.getColumnIndex(columnName); + if (columnIndex < 0) + { + throw new DatasetEditException( + "Column '" + columnName + "' does not exist in table '" + table.getName() + "'."); + } + final DatasetColumn column = table.getColumns().get(columnIndex); + + if (column.pending()) + { + final List pending = pendingColumns.get(tableKey); + final int pendingIndex = pending == null ? -1 : indexOfColumn(pending, columnName); + applyPendingColumnsChange("Delete pending column", + () -> removePendingColumn(tableKey, columnName), + () -> restorePendingColumn(tableKey, pendingIndex, columnName)); + return; + } + + final String key = columnName.toUpperCase(Locale.ENGLISH); + final List rowElements = index.getRowElements(tableKey); + for (final FlatXmlElement element : rowElements) + { + boolean hasColumn = false; + int remaining = 0; + for (final FlatXmlAttribute attribute : element.attributes()) + { + if (attribute.name().toUpperCase(Locale.ENGLISH).equals(key)) + { + hasColumn = true; + } + else + { + remaining++; + } + } + if (hasColumn && remaining == 0) + { + throw new DatasetEditException("Cannot delete column '" + column.name() + "' from table '" + + table.getName() + "' because it would leave a row with no values."); + } + } + + final Map changes = new LinkedHashMap<>(); + changes.put(key, null); + final CharsetEncoder encoder = currentEncoder(); + final String text = document.get(); + final List edits = new ArrayList<>(); + for (final FlatXmlElement element : rowElements) + { + final String rewritten = + StartTagRewriter.rewrite(text, element, table.getColumns(), changes, Map.of(), encoder); + if (rewritten != null) + { + edits.add(new ReplaceEdit(element.nameEndOffset(), + element.attributesEndOffset() - element.nameEndOffset(), rewritten)); + } + } + if (edits.isEmpty()) + { + return; + } + apply(edits); + refreshInternal(ChangeOrigin.EDIT); + } + + /** + * Runs a pendingColumns change that makes no text edit of its own: when a document undo manager is + * connected, through a custom operation on its undo context, so the change still becomes its own step + * in the document's undo history instead of being invisible to undo; otherwise directly. + */ + private void applyPendingColumnsChange(final String label, final Runnable doIt, final Runnable undoIt) + { + final PendingColumnsOperation operation = new PendingColumnsOperation(label, doIt, undoIt); + final IDocumentUndoManager undoManager = + DocumentUndoManagerRegistry.getDocumentUndoManager(document); + try + { + if (undoManager == null) + { + operation.execute(null, null); + } + else + { + operation.addContext(undoManager.getUndoContext()); + final IOperationHistory history = OperationHistoryFactory.getOperationHistory(); + history.execute(operation, null, null); + } + } + catch (final ExecutionException e) + { + throw new IllegalStateException("A pending column change failed.", e); + } + } + + private void removePendingColumn(final String tableKey, final String columnName) + { + final List pending = pendingColumns.get(tableKey); + if (pending == null) + { + return; + } + final String columnKey = columnName.toUpperCase(Locale.ENGLISH); + pending.removeIf(name -> name.toUpperCase(Locale.ENGLISH).equals(columnKey)); + if (pending.isEmpty()) + { + pendingColumns.remove(tableKey); + } + } + + private void restorePendingColumn(final String tableKey, final int index, final String columnName) + { + if (index < 0) + { + return; + } + pendingColumns.computeIfAbsent(tableKey, unused -> new ArrayList<>()).add(index, columnName); + } + + private void renamePendingColumn(final String tableKey, final String oldName, final String newName) + { + final List pending = pendingColumns.get(tableKey); + if (pending == null) + { + return; + } + final int index = indexOfColumn(pending, oldName); + if (index >= 0) + { + pending.set(index, newName); + } + } + + private static int indexOfColumn(final List names, final String name) + { + final String columnKey = name.toUpperCase(Locale.ENGLISH); + for (int i = 0; i < names.size(); i++) + { + if (names.get(i).toUpperCase(Locale.ENGLISH).equals(columnKey)) + { + return i; + } + } + return -1; + } + + /** + * An undoable operation for a pendingColumns change that has no text edit of its own to carry it: it + * shares the connected document undo manager's own undo context, so undo and redo interleave with text + * edits in the same chronological order the user made them. + */ + private final class PendingColumnsOperation extends AbstractOperation + { + private final Runnable doIt; + + private final Runnable undoIt; + + private PendingColumnsOperation(final String label, final Runnable doIt, final Runnable undoIt) + { + super(label); + this.doIt = doIt; + this.undoIt = undoIt; + } + + @Override + public IStatus execute(final IProgressMonitor monitor, final IAdaptable info) + { + return runAndRefresh(doIt); + } + + @Override + public IStatus redo(final IProgressMonitor monitor, final IAdaptable info) + { + return runAndRefresh(doIt); + } + + @Override + public IStatus undo(final IProgressMonitor monitor, final IAdaptable info) + { + return runAndRefresh(undoIt); + } + + private IStatus runAndRefresh(final Runnable action) + { + action.run(); + stale = true; + refreshInternal(ChangeOrigin.EDIT); + return Status.OK_STATUS; + } } @Override @@ -710,6 +1022,8 @@ public void rebind(final IDocument newDocument, final DtdSource newDtdSource) this.document = newDocument; this.dtdSource = newDtdSource; this.dtdCache.clear(); + this.pendingColumnsHistory.clear(); + this.lastRefreshModificationStamp = IDocumentExtension4.UNKNOWN_MODIFICATION_STAMP; document.addDocumentListener(documentListener); stale = true; refresh(); @@ -721,11 +1035,15 @@ private void refreshInternal(final ChangeOrigin origin) { return; } + final long modificationStamp = currentModificationStamp(); + restorePendingColumns(modificationStamp); final String text = document.get(); final FlatXmlParseResult parse = FlatXmlParser.parse(text); final DtdResolution dtdResolution = resolveDtd(parse.doctype()); final FlatXmlModelBuilder.Result built = FlatXmlModelBuilder.build(text, parse, dtdResolution.declarations(), options, pendingColumns); + prunePendingColumns(built.model().getTables()); + recordPendingColumns(modificationStamp); final List problems = FlatXmlValidator.validate(parse, built.index(), built.model().getTables(), dtdResolution.state(), dtdResolution.declarations(), options); final DatasetModel oldModel = model; @@ -736,6 +1054,96 @@ private void refreshInternal(final ChangeOrigin origin) notifyListeners(new DatasetModelChangeEvent(oldModel, model, origin)); } + /** + * Drops each table's pending columns that a fresh build now finds backed by data or the DTD, and + * drops a table's whole entry once the table itself no longer exists in the model, so a pending + * column never lingers once it is no longer needed. + */ + private void prunePendingColumns(final List tables) + { + final Set tableKeys = new HashSet<>(); + for (final DatasetTable table : tables) + { + tableKeys.add(table.getKey()); + final List pending = pendingColumns.get(table.getKey()); + if (pending == null) + { + continue; + } + pending.removeIf(name -> isBackedByRealColumn(table, name)); + if (pending.isEmpty()) + { + pendingColumns.remove(table.getKey()); + } + } + pendingColumns.keySet().removeIf(key -> !tableKeys.contains(key)); + } + + private static boolean isBackedByRealColumn(final DatasetTable table, final String name) + { + final int columnIndex = table.getColumnIndex(name); + return columnIndex >= 0 && !table.getColumns().get(columnIndex).pending(); + } + + /** + * Returns the document's current modification stamp, or {@link IDocumentExtension4#UNKNOWN_MODIFICATION_STAMP} + * when it does not support one. + */ + private long currentModificationStamp() + { + if (document instanceof final IDocumentExtension4 extension) + { + return extension.getModificationStamp(); + } + return IDocumentExtension4.UNKNOWN_MODIFICATION_STAMP; + } + + /** + * Restores pendingColumns from the snapshot recorded for modificationStamp, when that stamp differs + * from the previous refresh's stamp and a snapshot was recorded for it. This is how the pending + * columns that belonged to an earlier document state come back once undo or redo, which restores the + * document's modification stamp along with its text, returns the document to that earlier state. + */ + private void restorePendingColumns(final long modificationStamp) + { + if (modificationStamp == lastRefreshModificationStamp) + { + return; + } + final Map> snapshot = pendingColumnsHistory.get(modificationStamp); + if (snapshot == null) + { + return; + } + pendingColumns.clear(); + for (final Map.Entry> entry : snapshot.entrySet()) + { + pendingColumns.put(entry.getKey(), new ArrayList<>(entry.getValue())); + } + } + + /** + * Records a deep copy of pendingColumns under modificationStamp, so that a later undo or redo back to + * this exact modification stamp can restore it, then discards the oldest recorded snapshot once there + * are more than {@link #PENDING_COLUMNS_HISTORY_LIMIT} of them. + */ + private void recordPendingColumns(final long modificationStamp) + { + final Map> snapshot = new LinkedHashMap<>(); + for (final Map.Entry> entry : pendingColumns.entrySet()) + { + snapshot.put(entry.getKey(), new ArrayList<>(entry.getValue())); + } + pendingColumnsHistory.put(modificationStamp, snapshot); + lastRefreshModificationStamp = modificationStamp; + if (pendingColumnsHistory.size() > PENDING_COLUMNS_HISTORY_LIMIT) + { + final Iterator oldest = pendingColumnsHistory.keySet().iterator(); + oldest.next(); + oldest.remove(); + } + } + private DtdResolution resolveDtd(final FlatXmlDoctype doctype) { if (doctype == 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 index 00224fe5..fabbdf83 100644 --- 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 @@ -36,6 +36,7 @@ import org.dbunit.eclipse.dataset.core.edit.ChangeOrigin; import org.dbunit.eclipse.dataset.core.edit.DatasetEditException; 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; @@ -1202,6 +1203,422 @@ void testMoveRows_whenMovingPastTheLastRow_throwsAndChangesNothing() assertThat(document.get()).as("The document must be unchanged.").isEqualTo(original); } + @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 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); + 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_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); + }); + } + private static void withUndoManager(final IDocument document, final UndoManagerConsumer consumer) throws Exception { From e9b98baa2f72921a7743d4545fda5a3f1c069c0f Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Thu, 24 Sep 2026 11:52:44 -0500 Subject: [PATCH 011/170] feat(flatxml): Add, rename, and delete tables * Add addTable, renameTable, and deleteTable to FlatXmlDatasetDocument, replacing their unimplemented stubs. * Add inserts a new, empty element as the last child of the root, placing it on its own line before the end tag, or turning a self-closing root into an open and close tag around it, and records any given column names as pending; it rejects an invalid name, a name that already exists, and the reserved name "dataset". * Rename replaces the name in the start tag, and the end tag when one is present, of every element of the table wherever it appears in the document, and moves the table's pending columns to the renamed key; it applies the same three rejections as add. * Delete removes the whole line, or just the element when inline with other content, of every element of the table, and drops its pending columns; a DTD-declared table remains in the model as declared-only. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr --- .../dataset/core/edit/DatasetDocument.java | 11 + .../core/flatxml/FlatXmlDatasetDocument.java | 146 ++++- .../flatxml/FlatXmlDatasetDocumentTest.java | 585 ++++++++++++++++++ 3 files changed, 739 insertions(+), 3 deletions(-) diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/DatasetDocument.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/DatasetDocument.java index 53911dfe..37053b00 100644 --- a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/DatasetDocument.java +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/edit/DatasetDocument.java @@ -23,6 +23,7 @@ import java.util.List; import org.dbunit.eclipse.dataset.core.model.DatasetModel; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; /** * An editable dataset bound to its source. Not thread-safe: use it from one thread (the UI thread in the @@ -145,6 +146,16 @@ public interface DatasetDocument */ void deleteColumn(String tableKey, String columnName); + /** + * Returns the key a table with this name would have: the identity {@link DatasetTable#getKey()} + * carries across refreshes, accounting for this document's table-name case sensitivity. Callers use + * this to predict a table's key before an edit that creates or renames it completes. + * + * @param tableName The name to compute the key for. + * @return The key. + */ + String tableKeyOf(String tableName); + /** * Appends an empty table. * diff --git a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java index 604718fd..a5de102d 100644 --- a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java @@ -857,19 +857,159 @@ private IStatus runAndRefresh(final Runnable action) @Override public void addTable(final String tableName, final List columnNames) { - throw new UnsupportedOperationException("addTable is not implemented yet."); + refresh(); + if (!getModel().isEditable()) + { + throw new DatasetEditException("Cannot edit because the source has errors that block " + + "editing."); + } + if (!XmlNames.isValidName(tableName)) + { + throw new DatasetEditException("'" + tableName + "' is not a valid table name."); + } + if (isReservedRootName(tableName)) + { + throw new DatasetEditException("'" + tableName + "' is reserved for the root element."); + } + for (final DatasetTable existing : getModel().getTables()) + { + if (sameTableName(existing.getName(), tableName)) + { + throw new DatasetEditException("A table named '" + tableName + "' already exists."); + } + } + final List seenColumnNames = new ArrayList<>(); + for (final String columnName : columnNames) + { + if (!XmlNames.isValidName(columnName)) + { + throw new DatasetEditException("'" + columnName + "' is not a valid column name."); + } + final String columnKey = columnName.toUpperCase(Locale.ENGLISH); + for (final String seenColumnName : seenColumnNames) + { + if (seenColumnName.toUpperCase(Locale.ENGLISH).equals(columnKey)) + { + throw new DatasetEditException("Table '" + tableName + "' already has a column named '" + + columnName + "'."); + } + } + seenColumnNames.add(columnName); + } + + apply(List.of(insertAsLastChildOfRoot("<" + tableName + "/>"))); + if (!columnNames.isEmpty()) + { + pendingColumns.put(tableKeyOf(tableName), new ArrayList<>(columnNames)); + } + refreshInternal(ChangeOrigin.EDIT); } @Override public void renameTable(final String tableKey, final String newTableName) { - throw new UnsupportedOperationException("renameTable is not implemented yet."); + refresh(); + if (!getModel().isEditable()) + { + throw new DatasetEditException("Cannot edit because the source has errors that block " + + "editing."); + } + final DatasetTable tableToRename = getModel().findTable(tableKey) + .orElseThrow(() -> new DatasetEditException("There is no table '" + tableKey + "'.")); + if (tableToRename.isDeclaredOnly()) + { + throw new DatasetEditException("Cannot rename table '" + tableToRename.getName() + + "' because it exists only in the DTD; change the DTD instead."); + } + if (!XmlNames.isValidName(newTableName)) + { + throw new DatasetEditException("'" + newTableName + "' is not a valid table name."); + } + if (isReservedRootName(newTableName)) + { + throw new DatasetEditException("'" + newTableName + "' is reserved for the root element."); + } + for (final DatasetTable existing : getModel().getTables()) + { + if (!existing.getKey().equals(tableKey) && sameTableName(existing.getName(), newTableName)) + { + throw new DatasetEditException("A table named '" + newTableName + "' already exists."); + } + } + + final List elements = index.getAllElementsInOrder(tableKey); + final List edits = new ArrayList<>(); + for (final FlatXmlElement element : elements) + { + edits.add(new ReplaceEdit(element.offset() + 1, element.name().length(), newTableName)); + if (!element.selfClosing()) + { + edits.add(new ReplaceEdit(element.endTagOffset() + 2, element.name().length(), + newTableName)); + } + } + if (!edits.isEmpty()) + { + apply(edits); + } + + final List pending = pendingColumns.remove(tableKey); + if (pending != null) + { + pendingColumns.put(tableKeyOf(newTableName), pending); + } + stale = true; + refreshInternal(ChangeOrigin.EDIT); } @Override public void deleteTable(final String tableKey) { - throw new UnsupportedOperationException("deleteTable is not implemented yet."); + refresh(); + if (!getModel().isEditable()) + { + throw new DatasetEditException("Cannot edit because the source has errors that block " + + "editing."); + } + final DatasetTable tableToDelete = getModel().findTable(tableKey) + .orElseThrow(() -> new DatasetEditException("There is no table '" + tableKey + "'.")); + if (tableToDelete.isDeclaredOnly()) + { + throw new DatasetEditException("Cannot delete table '" + tableToDelete.getName() + + "' because it exists only in the DTD; change the DTD instead."); + } + + final List elements = index.getAllElementsInOrder(tableKey); + final List edits = new ArrayList<>(); + for (final FlatXmlElement element : elements) + { + final IRegion region = layout.lineExtent(element); + edits.add(new DeleteEdit(region.getOffset(), region.getLength())); + } + if (!edits.isEmpty()) + { + apply(edits); + } + + pendingColumns.remove(tableKey); + stale = true; + refreshInternal(ChangeOrigin.EDIT); + } + + private boolean isReservedRootName(final String tableName) + { + return sameTableName("dataset", tableName); + } + + private boolean sameTableName(final String oneName, final String otherName) + { + return tableKeyOf(oneName).equals(tableKeyOf(otherName)); + } + + @Override + public String tableKeyOf(final String tableName) + { + return options.caseSensitiveTableNames() ? tableName : tableName.toUpperCase(Locale.ENGLISH); } @Override 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 index fabbdf83..fbbd9fe0 100644 --- 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 @@ -1619,6 +1619,591 @@ void testDeleteColumn_whenColumnIsDtdDeclared_keepsItInTheModel() throws Excepti }); } + @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_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_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 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 { From 6d511d7e8acac3412c7396254f457f77622c65eb Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Thu, 24 Sep 2026 12:02:28 -0500 Subject: [PATCH 012/170] feat(core): Detect flat XML dataset files by content * Add FlatXmlContentDescriber, an XMLContentDescriber that recognizes a dbUnit flat XML dataset by its root element and, when present, its first child, without loading any external DTD or entity the document refers to. * Declare the org.dbunit.eclipse.dataset.flatxml content type in a new core plugin.xml, bound to an .xml file extension and to the new describer, with its display name externalized to plugin.properties. * Package plugin.xml into the bundle and import the JDK's SAX and JAXP packages the describer needs. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr --- .../META-INF/MANIFEST.MF | 5 +- .../build.properties | 3 +- .../plugin.properties | 1 + .../plugin.xml | 14 ++ .../core/flatxml/FlatXmlContentDescriber.java | 198 ++++++++++++++++++ .../flatxml/FlatXmlContentDescriberTest.java | 140 +++++++++++++ 6 files changed, 359 insertions(+), 2 deletions(-) create mode 100644 bundles/org.dbunit.eclipse.dataset.core/plugin.xml create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlContentDescriber.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlContentDescriberTest.java 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 16938f42..1451191e 100644 --- a/bundles/org.dbunit.eclipse.dataset.core/META-INF/MANIFEST.MF +++ b/bundles/org.dbunit.eclipse.dataset.core/META-INF/MANIFEST.MF @@ -12,7 +12,10 @@ 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", org.eclipse.core.commands;bundle-version="3.12.0" -Import-Package: org.eclipse.osgi.util;version="1.1.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", 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/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlContentDescriber.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlContentDescriber.java new file mode 100644 index 00000000..f334ed82 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlContentDescriber.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 java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.io.StringReader; + +import javax.xml.parsers.ParserConfigurationException; +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; + +import org.eclipse.core.runtime.content.IContentDescription; +import org.eclipse.core.runtime.content.XMLContentDescriber; +import org.xml.sax.Attributes; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; +import org.xml.sax.helpers.DefaultHandler; + +/** + * Recognizes dbUnit flat XML dataset files by content, so Eclipse can open them in the dbUnit Dataset + * Editor without relying on the file extension alone. + * + * @since 1.0.0 + */ +public final class FlatXmlContentDescriber extends XMLContentDescriber +{ + @Override + public int describe(final InputStream contents, final IContentDescription description) throws IOException + { + final int xmlResult = super.describe(contents, description); + if (xmlResult == INVALID) + { + return INVALID; + } + contents.reset(); + if (isBlank(contents)) + { + return INDETERMINATE; + } + return describeRoot(new InputSource(contents)); + } + + @Override + public int describe(final Reader contents, final IContentDescription description) throws IOException + { + final int xmlResult = super.describe(contents, description); + if (xmlResult == INVALID) + { + return INVALID; + } + contents.reset(); + if (isBlank(contents)) + { + return INDETERMINATE; + } + return describeRoot(new InputSource(contents)); + } + + /** + * Returns whether the rest of the content is empty or all whitespace, leaving it positioned back at + * the mark either way: a blank file could be any kind of XML, so it is indeterminate rather than + * claimed as a dataset. + */ + private static boolean isBlank(final InputStream contents) throws IOException + { + contents.mark(Integer.MAX_VALUE); + try + { + int value; + while ((value = contents.read()) != -1) + { + if (!Character.isWhitespace(value)) + { + return false; + } + } + return true; + } + finally + { + contents.reset(); + } + } + + private static boolean isBlank(final Reader contents) throws IOException + { + contents.mark(Integer.MAX_VALUE); + try + { + int value; + while ((value = contents.read()) != -1) + { + if (!Character.isWhitespace(value)) + { + return false; + } + } + return true; + } + finally + { + contents.reset(); + } + } + + private static int describeRoot(final InputSource source) + { + final RootHandler handler = new RootHandler(); + try + { + final SAXParserFactory factory = SAXParserFactory.newInstance(); + factory.setNamespaceAware(false); + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + final SAXParser parser = factory.newSAXParser(); + parser.parse(source, handler); + return VALID; + } + catch (final DescriptionKnown known) + { + return known.result; + } + catch (final ParserConfigurationException | SAXException | IOException exception) + { + return handler.rootStarted ? VALID : INDETERMINATE; + } + } + + /** + * Thrown by {@link RootHandler} to stop parsing as soon as the result is known, instead of reading the + * rest of a file that might be large. + */ + private static final class DescriptionKnown extends SAXException + { + private final int result; + + private DescriptionKnown(final int result) + { + this.result = result; + } + } + + private static final class RootHandler extends DefaultHandler + { + private boolean rootStarted; + + private int depth; + + @Override + public InputSource resolveEntity(final String publicId, final String systemId) + { + return new InputSource(new StringReader("")); + } + + @Override + public void startElement(final String uri, final String localName, final String qName, + final Attributes attributes) throws SAXException + { + depth++; + if (depth == 1) + { + rootStarted = true; + if (!"dataset".equals(qName)) + { + throw new DescriptionKnown(INVALID); + } + return; + } + if (depth == 2) + { + final boolean isFullXmlDataSetTable = + "table".equals(qName) && attributes.getValue("name") != null; + throw new DescriptionKnown(isFullXmlDataSetTable ? INVALID : VALID); + } + } + } +} 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..55372037 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlContentDescriberTest.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.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_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); + } +} From aff826bae3a902718bcf3f6d446676f098c6e0d7 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Thu, 24 Sep 2026 12:14:57 -0500 Subject: [PATCH 013/170] feat(editor): Add UI messages, images, and preference defaults * Add the UI bundle's Messages (NLS bootstrap, no keys yet) and DatasetImages, which registers the editor's icons in DatasetUiPlugin's image registry and exposes them by key. * Add releng/icons/GenerateIcons.java, a single-file program that draws the toolbar, object, and wizard banner icons with java.awt, at 16x16 (75x66 for the banner) and their 32x32 @2x variants; run it and commit the generated PNGs under bundles/org.dbunit.eclipse.dataset.ui/icons/. * Add PreferenceKeys and DatasetPreferenceInitializer, setting the NULL display text, column-sensing, and case-sensitive-table-name defaults in the UI bundle's instance-scope preference store, wired up through a new UI plugin.xml. * Package plugin.xml and icons/ into the bundle and import the OSGi NLS utility package the new Messages class needs. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr --- .../META-INF/MANIFEST.MF | 1 + .../build.properties | 4 +- .../icons/etool16/add_column.png | Bin 0 -> 174 bytes .../icons/etool16/add_column@2x.png | Bin 0 -> 269 bytes .../icons/etool16/add_table.png | Bin 0 -> 204 bytes .../icons/etool16/add_table@2x.png | Bin 0 -> 361 bytes .../icons/etool16/delete_column.png | Bin 0 -> 293 bytes .../icons/etool16/delete_column@2x.png | Bin 0 -> 506 bytes .../icons/etool16/delete_rows.png | Bin 0 -> 269 bytes .../icons/etool16/delete_rows@2x.png | Bin 0 -> 456 bytes .../icons/etool16/duplicate_rows.png | Bin 0 -> 115 bytes .../icons/etool16/duplicate_rows@2x.png | Bin 0 -> 190 bytes .../icons/etool16/fill_down.png | Bin 0 -> 219 bytes .../icons/etool16/fill_down@2x.png | Bin 0 -> 291 bytes .../icons/etool16/insert_row_above.png | Bin 0 -> 166 bytes .../icons/etool16/insert_row_above@2x.png | Bin 0 -> 253 bytes .../icons/etool16/insert_row_below.png | Bin 0 -> 164 bytes .../icons/etool16/insert_row_below@2x.png | Bin 0 -> 256 bytes .../icons/etool16/set_null.png | Bin 0 -> 303 bytes .../icons/etool16/set_null@2x.png | Bin 0 -> 511 bytes .../icons/obj16/dataset.png | Bin 0 -> 112 bytes .../icons/obj16/dataset@2x.png | Bin 0 -> 302 bytes .../icons/wizban/new_dataset_wiz.png | Bin 0 -> 485 bytes .../icons/wizban/new_dataset_wiz@2x.png | Bin 0 -> 690 bytes .../org.dbunit.eclipse.dataset.ui/plugin.xml | 7 + .../eclipse/dataset/ui/DatasetImages.java | 135 ++++++++++ .../eclipse/dataset/ui/DatasetUiPlugin.java | 7 + .../dbunit/eclipse/dataset/ui/Messages.java | 42 +++ .../eclipse/dataset/ui/messages.properties | 0 .../DatasetPreferenceInitializer.java | 42 +++ .../ui/preferences/PreferenceKeys.java | 66 +++++ .../eclipse/dataset/ui/DatasetImagesTest.java | 52 ++++ .../DatasetPreferenceInitializerTest.java | 58 ++++ releng/icons/GenerateIcons.java | 248 ++++++++++++++++++ src/site/asciidoc/devguide.adoc | 1 + 35 files changed, 662 insertions(+), 1 deletion(-) create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/add_column.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/add_column@2x.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/add_table.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/add_table@2x.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/delete_column.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/delete_column@2x.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/delete_rows.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/delete_rows@2x.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/duplicate_rows.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/duplicate_rows@2x.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/fill_down.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/fill_down@2x.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/insert_row_above.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/insert_row_above@2x.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/insert_row_below.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/insert_row_below@2x.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/set_null.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/set_null@2x.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/obj16/dataset.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/obj16/dataset@2x.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/wizban/new_dataset_wiz.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/icons/wizban/new_dataset_wiz@2x.png create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/plugin.xml create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/DatasetImages.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/Messages.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/messages.properties create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/preferences/DatasetPreferenceInitializer.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/preferences/PreferenceKeys.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/DatasetImagesTest.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/preferences/DatasetPreferenceInitializerTest.java create mode 100644 releng/icons/GenerateIcons.java diff --git a/bundles/org.dbunit.eclipse.dataset.ui/META-INF/MANIFEST.MF b/bundles/org.dbunit.eclipse.dataset.ui/META-INF/MANIFEST.MF index 718cf280..bc441b1f 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/META-INF/MANIFEST.MF +++ b/bundles/org.dbunit.eclipse.dataset.ui/META-INF/MANIFEST.MF @@ -18,4 +18,5 @@ Require-Bundle: org.dbunit.eclipse.dataset.core;bundle-version="[1.0.0,2.0.0)", org.eclipse.ui.ide;bundle-version="3.22.0", org.eclipse.ui.workbench.texteditor;bundle-version="3.17.0", org.eclipse.nebula.widgets.nattable.core;bundle-version="[2.7.0,3.0.0)" +Import-Package: org.eclipse.osgi.util;version="1.1.0" Export-Package: org.dbunit.eclipse.dataset.ui;x-internal:=true diff --git a/bundles/org.dbunit.eclipse.dataset.ui/build.properties b/bundles/org.dbunit.eclipse.dataset.ui/build.properties index c1a49c55..f64c0d9f 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/build.properties +++ b/bundles/org.dbunit.eclipse.dataset.ui/build.properties @@ -2,4 +2,6 @@ source.. = src/main/java/ output.. = target/classes/ bin.includes = META-INF/,\ .,\ - plugin.properties + plugin.properties,\ + plugin.xml,\ + icons/ diff --git a/bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/add_column.png b/bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/add_column.png new file mode 100644 index 0000000000000000000000000000000000000000..b380d7bd8d1f69f6e1eb0a7f8c5d63d646ea7125 GIT binary patch literal 174 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`rJgR1Ar*1S2@AwF%*=h9V0?Mm z!Ln&`f}ifnCd|6eqW5lS-_kY|uKIGtv{%iBR}kJ_D<_E*8~qFw`|Q=-EKh8f-)jxq(ZJz!qU#v{gj!El$hiBdpF z!@SOz^O;>-I-MLH4PfLTa^SYU*y1zyy1Z4m3UbsM^yTKvdAxl3#rscLcjasfRNH&T Q9_T*?Pgg&ebxsLQ07s~A?EnA( literal 0 HcmV?d00001 diff --git a/bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/add_table.png b/bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/add_table.png new file mode 100644 index 0000000000000000000000000000000000000000..e2dc19a43f0b61c7793158740fc3646d65bf21f3 GIT binary patch literal 204 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`lRaG=Ln`9dPB_impdjF)nbSEh z*NgkXgHRo}+Z&`x0(-32B=zXeJbd8HC&Op_bBY(Lyy398@a44T*<|+xq8FeyYBb;{|%>~Xy!CW89vaR&$c96e%50K|KO;@HODGn y^%~4SD1R)x@6qJ%>OPsgpZjZmHEZ8$5S`=q>Pp(ZJOiNf7(8A5T-G@yGywofAX01q literal 0 HcmV?d00001 diff --git a/bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/add_table@2x.png b/bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/add_table@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..0fc693a7e637ea3e9132c1ae472482fd5369faa5 GIT binary patch literal 361 zcmV-v0ha!WP)C^$ky`z9O z!T@HA-O1x}`(q18ItAEp?*~O<00000NkvXX Hu0mjfoUEK) literal 0 HcmV?d00001 diff --git a/bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/delete_column.png b/bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/delete_column.png new file mode 100644 index 0000000000000000000000000000000000000000..9971565f4be696f84bb32060b19bf46e65eaa74c GIT binary patch literal 293 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Z#-QbLn`8u6C_L?_$4g3vGzej z=yqpkSI0l|=FR*6LSnX+1q0_jo^Sv8kAyo+Qs9jf&O2n-!GDQoQu~U}(Va;Q9>PwS z{-0X%txSYjjrpg+&*+2yrW?jIsxR(yzxuVv$O&o0ak~Fyv2+j#yUP7MT|uu zvOV6v=ijJfkz-RR+|#Cz&Gu7Lj9WqKnbI5(r`4>=is578rwHE0r7jU6iQb(nI(f6b nzC?2xOWMjG_{6l!s*91qx6G(Dl85^|(BBN6u6{1-oD!M<=iYP- literal 0 HcmV?d00001 diff --git a/bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/delete_column@2x.png b/bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/delete_column@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..dd9e26e53b7b041be38abbe81a5ebcaa3ec1a896 GIT binary patch literal 506 zcmV z?axN!1XhIIPGff@k;6uAoX;B}CvYO(G7`_56v#bqoR1qNC-5RVD-jFw&SuC72!?M! z6mdVemC5l9m?S6Q7BFXAFwA3}=xYSX1GojuSub~_onl^0#l}F1XzaKWIc(f_rBrMb z5P*BWwH=t9^`E!)4IsC3x8Tn4H-8Jb!cxow6Iq0*JiwE4nGhnR<7p;>BiGe}*kmnW z$tzSt%r~xv96A11?FX=q^RFaAMGog$9T8K&N<|p&33(B* z7&PEcV4&Eo;DA-PLqkQ;<&XhRbs)i;!T$e&_zH$36o52?0nJ)LW9Bd!MHTAI7|&*13);b?J!*PRUn<%egs7xuL0rRXW=dYX$H|CK0z0N@MM%Q zi2OgJxfvx)aE1X0;0kSAv4a9|X@&uUhQLxHHq9`AY6#Yp=(iG=W*8t=9nk;)J%2KV TbUR)d00000NkvXXu0mjf1PRvUqjU`tJ5VSq%3Y{$(ghGQp_ZJN#PKH*2r<&A zvfuN})Q#N?2LJf2U`Kmfbj}7^dd%G)vyroAht4srnLtZ#)BIjAFF#clNuZ^NZ7P#~ zH*?AtNkGgw*V3N@W(|w9L$sKy>DY#_GUqT2(Th2UZU}4hFbsDJUW;7Lyv1tde2StN zW4_XNGJRI*1Cu1#rrsq`jG`!~d^kVLSME^!R^=G;s6SrF>y&XEUs3N8JiujqpHGry zg{eQ!--rpunddjoqn~O7g|1QnH1DRY#vphq#1ATFl&GVRt_3H_i(P=O5Hj=L0)Th3 zo0>Uz>xH%6-U9?d_H#QRGgk?r8~{%%nnlk2wDD=rrfsJnt!XHq0?Mv)-gQq?6 literal 0 HcmV?d00001 diff --git a/bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/duplicate_rows.png b/bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/duplicate_rows.png new file mode 100644 index 0000000000000000000000000000000000000000..fd27284a1836c243fc68d013f0ee387cde8e8815 GIT binary patch literal 115 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`R-P`7Ar*1S2@-M(mM?#9uPG60 ze}iE?r+Me0<{Jz_2D|EBF_qlrNG$n#g_FOeb%()Q9tGCLub101Fch-c|7p59`8&`Q N22WQ%mvv4FO#uC_B)R|q literal 0 HcmV?d00001 diff --git a/bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/duplicate_rows@2x.png b/bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/duplicate_rows@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..95406676e8484b5d842560157d0bb1f303eb36b1 GIT binary patch literal 190 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJR!6?jEhT;tYCJN mHHdP*z$L81>3*<-fnmB@(W`pp+1@}0F?hQAxvX>oNU|MBGJRWW^K@A T+xLDe(6tPnu6{1-oD!M=u5u4JP^|S-xZ=r_>ffP z%at;FsQ7eU;2Bn)!P?YaWoCzGSP=mc5CIVofu9MaM>E4cOQ-;KK8PeUn`U_#YqQ4h zLIhmry|jm`HlV^wd;-4nPVBW=^Iia2;u1jNQ01u4VZ#QNbP|*R5kBq)0GGs&z@*^- zDWNXs*Itqp_W6D8n{a@TxTEJAOE^GCP&I8h5GcVD4tOMZ!-3`p+zy1-wvT7oh*hEc p;f2OC?102y!hz(F&v8!}!vl~rOoV19v8(_9002ovPDHLkV1f{4dPo2O literal 0 HcmV?d00001 diff --git a/bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/insert_row_above.png b/bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/insert_row_above.png new file mode 100644 index 0000000000000000000000000000000000000000..2beeab2a8fbbc3ea2992d54e992297ab38b478bf GIT binary patch literal 166 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`xt=bLAr*1S2@6y=%*=h9V0?Mm z!Ln&`f}iYge-q$$`jm?ca*TQ@0&X}hGX@N1z7z6wVZW-Cg4 zZ~o!3n}O{FAG1Wm&X$zfCz)(*ZU1{7m0+vz-Qj5>w}fp%S0Ims0Ruz%vL~}2eA?#> Pw3xxu)z4*}Q$iB}s-imk literal 0 HcmV?d00001 diff --git a/bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/insert_row_above@2x.png b/bundles/org.dbunit.eclipse.dataset.ui/icons/etool16/insert_row_above@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..c1373be76f654fa036ca3e7f3c707fcef2b9d39a GIT binary patch literal 253 zcmVeo0lS{uK1k zzzw>QI@AZ+BdYJl0pdgt7uqAL@(j?KCtfoH0S3`bEKa-!_l>9loGC6kfis_PIQ-Vx zGeEVt5$+T3Ne$3Uh|jzX8DpA6Cyn=heOy?y)<$WZbL)W}4V>LQw*JY^DXp7j0Gp53 zH#@C=r~x&QX8_ZR&D>Hs{LZq}?*PacvO+GFPru0V{c+W4CEQWZNWf9^Nk&m;TQ{I`hC|VZgz_x)ZC4 z_n_{G7{Hn0r4u;wWx`<|C*OfotBz1tyeBb`?gabHx%2{m60%Vfh15m>0000L4yUQ8GS%^^~Sz1WmydRb#?`obvQpr$tf{HrtbIG^((!EApNl|)> zSe-K>%}x9X+e)|1#e4JGA_l6UHObHx-twcQyN2`lixxAj3Dg{!Xm!NmGve!L{f z78PGXuSK#s7G9Jk9OAn8JqF=I5ysGPXD+7Auu~%b4U)_n}iVHZ#3i<5m%&s__rV$$b78(7FIe=?= zU!qI3ZC(Q1hhIQu35qSWK4U#VyJ%kd1>nqFa}|0AlVu zF(Y3MDLDy)o@Sj*P4Qi*A?KAM<2uVFO1HDdrlR;dGTuwxN6k35z}H}KLT1VLpm)x? zjOyb08hOaK+u_e1IpUyyMP~1;$=Fx?S0nlMbNJgH1!SB?c|-NY8sJhq)k!}2jqK+I zWSkyy34u2$XdH`gwcaGB6Mxp^sOy0y#X;j9*f$5776-_;^|f=L>MLkdywz6#3hTgj zd2Li2q61D(M%Pmt6l~V!An^CHF*~iZ-jUfk@5eH=9zp%VIB2H1WM`S!H-^$B#X# zZ?X%r-yX=GY^T58zTm-DPeBV7x7XL}>fY6C?BmmTTFBIQT`kdvL!oBZ{<3$^*<}Mx zB{PS|&AuX)!JFVODDgPgb;j@N)2A!)xcffJIxr+ZI`-bQB9EzrUEmqdrDsVFv%48{ z_1L!Tl(_JWqk?4-^C3=s(;a7X4wf}+V_xrT^wwH|1uO|s+1KEHfTKF*RBjsgHs&p| t6}drgau{6NC4vvF6Y~FW{L6bUV--VRvb9*nL0|wdc)I$ztaD0e0sz%@d0GGf literal 0 HcmV?d00001 diff --git a/bundles/org.dbunit.eclipse.dataset.ui/icons/wizban/new_dataset_wiz.png b/bundles/org.dbunit.eclipse.dataset.ui/icons/wizban/new_dataset_wiz.png new file mode 100644 index 0000000000000000000000000000000000000000..4df058dbcb5b99a487887684f192b31afe7e319b GIT binary patch literal 485 zcmeAS@N?(olHy`uVBq!ia0vp^-azcc!3HGd9krDh7#P=hx;TbZ#J!zm?Z@mW;8H7i z#PwKG@@!#;zD@)4hJ?+3XH0pjU|}p+{=nq)ltdK?-dS%Gs%|fu7jiKqKyQ1L?3*M< z4#gG$Cl9vokF>tOk(++_lTw|w-|cPBeJ|gsUb}bIoL%d7U-@vov{o#gRpS(I-j}7e zNAiz48pPko@ZGm%w}tkM-D^w_-F^4%@z-~@t9}1h{V`nnci)=OIihD`!&(bCc2)g4 z@#@rXlRvfh)ysuWd;JIui+DEiUSBHjJAwV8@xPXx<_b0mUAp{iQREB}kq_Q8R;*9r z`*?f*yfqfu56*f0hzxsVJMHDB0z>6H$L>3F%({H`yKKm<{IBP%R&v|v{C#xV#&yR7 zLB7TFCx6eI`sd`ySyMM`7BH#vPCUE5%Vdt}eyvLd*Z!>XI2`$TwGwYSieL-(z3lD< z!XkY)7S*jTzvdbHVe_RAP4oHOH@KmPx+ j>lSbVhbw~c`pkJs_~G~IhN8QG(Z}HF>gTe~DWM4fXQ}5t literal 0 HcmV?d00001 diff --git a/bundles/org.dbunit.eclipse.dataset.ui/icons/wizban/new_dataset_wiz@2x.png b/bundles/org.dbunit.eclipse.dataset.ui/icons/wizban/new_dataset_wiz@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..651b352b25f29d7c1beae5b7fec0a07b86d529d5 GIT binary patch literal 690 zcmeAS@N?(olHy`uVBq!ia0vp^(}1{zgAGU)$zLjBU|_2Bba4!+hOnd*)_(SGe`D-srs;1U<-tMi4*rZ*!Tae$oOf}E^ zQR3gz+vnFWd+zZUB)0lR(xaO_A>WR_b+cKssJk=WIOwMQcIl5By7biMo%Kkqzms?5 zu-2{_6=up)|GrN9DF3zh&%Vlt=TDzDpTF}|;Qxj2I3JtY6Homs-MpQ>;CI5aXB_u) zriu%nPg}C7{_dH>w-$baSg?L}WrTGIT=DDt1+Sm-7ytDw{hbi^&hE40pJTTsDt`q! zcZmouDB+6w(du38AJU}M?;Qy=mFYvy0pL)OW_GIU81;1vWlJlz%zIpg% zzE-uxU*A;z<@;B@dHBVAs_mSA%Qm%Nz8^xgqAL42IqG@G+brhU_}%H_EwrhevqM&z zG&2af=F0DfDb|J7W=B3gDST9x)A9J^<0Efj2?Q&B;*+>cOtrawSSu4q-qY33Wt~$( F69BiXScm`s literal 0 HcmV?d00001 diff --git a/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml b/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml new file mode 100644 index 00000000..60ec1316 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml @@ -0,0 +1,7 @@ + + + + + + + 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..684763dd --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/Messages.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; + +import org.eclipse.osgi.util.NLS; + +/** + * Externalized, user-visible strings of the dataset editor UI bundle. + * + * @since 1.0.0 + */ +public final class Messages extends NLS +{ + private static final String BUNDLE_NAME = "org.dbunit.eclipse.dataset.ui.messages"; + + 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/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..e69de29b 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/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/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/preferences/DatasetPreferenceInitializerTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/preferences/DatasetPreferenceInitializerTest.java new file mode 100644 index 00000000..5a4714f0 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/preferences/DatasetPreferenceInitializerTest.java @@ -0,0 +1,58 @@ +/* + * + * 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 static org.assertj.core.api.Assertions.assertThat; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlOptions; +import org.dbunit.eclipse.dataset.ui.DatasetUiPlugin; +import org.eclipse.jface.preference.IPreferenceStore; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link DatasetPreferenceInitializer} against the documented default preference values. + */ +class DatasetPreferenceInitializerTest +{ + @Test + void testInitializeDefaultPreferences_setsEveryDocumentedDefault() + { + new DatasetPreferenceInitializer().initializeDefaultPreferences(); + + final IPreferenceStore store = DatasetUiPlugin.getDefault().getPreferenceStore(); + assertThat(store.getDefaultString(PreferenceKeys.NULL_DISPLAY_TEXT)) + .as("The NULL display text must default to \"(null)\".").isEqualTo("(null)"); + assertThat(store.getDefaultBoolean(PreferenceKeys.ASSUME_COLUMN_SENSING)) + .as("Assuming column sensing must default to false.").isFalse(); + assertThat(store.getDefaultBoolean(PreferenceKeys.CASE_SENSITIVE_TABLE_NAMES)) + .as("Case-sensitive table names must default to false.").isFalse(); + } + + @Test + void testReadOptions_withDefaultPreferences_matchesDbUnitDefaults() + { + new DatasetPreferenceInitializer().initializeDefaultPreferences(); + + assertThat(PreferenceKeys.readOptions()) + .as("The default preferences must build dbUnit's own default options.") + .isEqualTo(FlatXmlOptions.DBUNIT_DEFAULTS); + } +} diff --git a/releng/icons/GenerateIcons.java b/releng/icons/GenerateIcons.java new file mode 100644 index 00000000..2d2889aa --- /dev/null +++ b/releng/icons/GenerateIcons.java @@ -0,0 +1,248 @@ +/* + * + * 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 + * + */ + +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.function.Consumer; + +import javax.imageio.ImageIO; + +/** + * Draws the dataset editor's icons as 16x16 (or, for the wizard banner, 75x66) PNGs plus their 32x32 + * {@code @2x} variants, into {@code bundles/org.dbunit.eclipse.dataset.ui/icons/}. Run from the + * repository root: {@code java releng/icons/GenerateIcons.java}. + */ +public class GenerateIcons +{ + private static final Path ICONS_ROOT = Paths.get("bundles/org.dbunit.eclipse.dataset.ui/icons"); + + private static final Color GRID = new Color(0x8C, 0x8C, 0x8C); + + private static final Color HIGHLIGHT = new Color(0x4A, 0x90, 0xD9); + + private static final Color PLUS = new Color(0x3C, 0x9A, 0x3C); + + private static final Color MINUS = new Color(0xD9, 0x53, 0x4F); + + public static void main(final String[] args) throws IOException + { + draw("obj16/dataset.png", 16, 16, GenerateIcons::paintDataset); + draw("etool16/insert_row_above.png", 16, 16, GenerateIcons::paintInsertRowAbove); + draw("etool16/insert_row_below.png", 16, 16, GenerateIcons::paintInsertRowBelow); + draw("etool16/duplicate_rows.png", 16, 16, GenerateIcons::paintDuplicateRows); + draw("etool16/delete_rows.png", 16, 16, GenerateIcons::paintDeleteRows); + draw("etool16/add_column.png", 16, 16, GenerateIcons::paintAddColumn); + draw("etool16/delete_column.png", 16, 16, GenerateIcons::paintDeleteColumn); + draw("etool16/add_table.png", 16, 16, GenerateIcons::paintAddTable); + draw("etool16/set_null.png", 16, 16, GenerateIcons::paintSetNull); + draw("etool16/fill_down.png", 16, 16, GenerateIcons::paintFillDown); + draw("wizban/new_dataset_wiz.png", 75, 66, GenerateIcons::paintNewDatasetWizban); + System.out.println("Generated 11 icons (22 files, including @2x) under " + ICONS_ROOT); + } + + private static void draw(final String relativePath, final int width, final int height, + final Consumer painter) throws IOException + { + saveIcon(relativePath, width, height, 1, painter); + saveIcon(withAt2x(relativePath), width, height, 2, painter); + } + + private static String withAt2x(final String relativePath) + { + final int dot = relativePath.lastIndexOf('.'); + return relativePath.substring(0, dot) + "@2x" + relativePath.substring(dot); + } + + private static void saveIcon(final String relativePath, final int width, final int height, + final int scale, final Consumer painter) throws IOException + { + final BufferedImage image = + new BufferedImage(width * scale, height * scale, BufferedImage.TYPE_INT_ARGB); + final Graphics2D g = image.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g.scale(scale, scale); + painter.accept(g); + g.dispose(); + + final Path outputPath = ICONS_ROOT.resolve(relativePath); + Files.createDirectories(outputPath.getParent()); + ImageIO.write(image, "png", outputPath.toFile()); + } + + private static void paintDataset(final Graphics2D g) + { + final int left = 2; + final int top = 2; + final int cellWidth = 4; + final int cellHeight = 4; + final int columns = 3; + final int rows = 3; + g.setColor(HIGHLIGHT); + g.fillRect(left, top, cellWidth * columns, cellHeight); + g.setColor(GRID); + g.setStroke(new BasicStroke(1f)); + for (int row = 0; row <= rows; row++) + { + final int y = top + row * cellHeight; + g.drawLine(left, y, left + cellWidth * columns, y); + } + for (int column = 0; column <= columns; column++) + { + final int x = left + column * cellWidth; + g.drawLine(x, top, x, top + cellHeight * rows); + } + } + + private static void paintInsertRowAbove(final Graphics2D g) + { + drawPlus(g, 8, 4, 2); + drawBar(g, 2, 9, 12, 4); + } + + private static void paintInsertRowBelow(final Graphics2D g) + { + drawBar(g, 2, 3, 12, 4); + drawPlus(g, 8, 12, 2); + } + + private static void paintDuplicateRows(final Graphics2D g) + { + drawBar(g, 1, 3, 11, 4); + drawBar(g, 4, 9, 11, 4); + } + + private static void paintDeleteRows(final Graphics2D g) + { + drawBar(g, 2, 6, 12, 4); + drawCross(g, 8, 8, 5); + } + + private static void paintAddColumn(final Graphics2D g) + { + drawBar(g, 9, 2, 4, 12); + drawPlus(g, 3, 4, 2); + } + + private static void paintDeleteColumn(final Graphics2D g) + { + drawBar(g, 6, 2, 4, 12); + drawCross(g, 8, 8, 5); + } + + private static void paintAddTable(final Graphics2D g) + { + final int left = 1; + final int top = 1; + final int cellSize = 4; + g.setColor(HIGHLIGHT); + g.fillRect(left, top, cellSize * 2, cellSize); + g.setColor(GRID); + g.setStroke(new BasicStroke(1f)); + for (int row = 0; row <= 2; row++) + { + final int y = top + row * cellSize; + g.drawLine(left, y, left + cellSize * 2, y); + } + for (int column = 0; column <= 2; column++) + { + final int x = left + column * cellSize; + g.drawLine(x, top, x, top + cellSize * 2); + } + drawPlus(g, 12, 12, 3); + } + + private static void paintSetNull(final Graphics2D g) + { + g.setColor(MINUS); + g.setStroke(new BasicStroke(2f)); + g.drawOval(3, 3, 10, 10); + g.drawLine(5, 11, 11, 5); + } + + private static void paintFillDown(final Graphics2D g) + { + g.setColor(HIGHLIGHT); + g.setStroke(new BasicStroke(2f)); + g.drawLine(8, 2, 8, 11); + g.drawLine(8, 13, 4, 9); + g.drawLine(8, 13, 12, 9); + } + + private static void paintNewDatasetWizban(final Graphics2D g) + { + final int left = 8; + final int top = 10; + final int cellWidth = 12; + final int cellHeight = 10; + final int columns = 3; + final int rows = 3; + g.setColor(HIGHLIGHT); + g.fillRect(left, top, cellWidth * columns, cellHeight); + g.setColor(GRID); + g.setStroke(new BasicStroke(1.5f)); + for (int row = 0; row <= rows; row++) + { + final int y = top + row * cellHeight; + g.drawLine(left, y, left + cellWidth * columns, y); + } + for (int column = 0; column <= columns; column++) + { + final int x = left + column * cellWidth; + g.drawLine(x, top, x, top + cellHeight * rows); + } + drawPlus(g, left + cellWidth * columns + 8, top + 4, 5); + } + + private static void drawBar(final Graphics2D g, final int x, final int y, final int width, + final int height) + { + g.setColor(HIGHLIGHT); + g.fillRect(x, y, width, height); + g.setColor(GRID); + g.setStroke(new BasicStroke(1f)); + g.drawRect(x, y, width, height); + } + + private static void drawPlus(final Graphics2D g, final int centerX, final int centerY, + final int armLength) + { + g.setColor(PLUS); + g.setStroke(new BasicStroke(2f)); + g.drawLine(centerX - armLength, centerY, centerX + armLength, centerY); + g.drawLine(centerX, centerY - armLength, centerX, centerY + armLength); + } + + private static void drawCross(final Graphics2D g, final int centerX, final int centerY, + final int armLength) + { + g.setColor(MINUS); + g.setStroke(new BasicStroke(2f)); + g.drawLine(centerX - armLength, centerY - armLength, centerX + armLength, centerY + armLength); + g.drawLine(centerX - armLength, centerY + armLength, centerX + armLength, centerY - armLength); + } +} diff --git a/src/site/asciidoc/devguide.adoc b/src/site/asciidoc/devguide.adoc index 2060b3d1..6a77e596 100644 --- a/src/site/asciidoc/devguide.adoc +++ b/src/site/asciidoc/devguide.adoc @@ -15,6 +15,7 @@ link:building.html[Building] covers the command-line build, and link:releasing.h |`features/org.dbunit.eclipse.feature` |The installable feature (and its generated source feature) |`releng/org.dbunit.eclipse.target` |Target platform definitions: baseline (the oldest supported Eclipse release) and latest |`releng/org.dbunit.eclipse.repository` |The p2 repository (update site) definition, `category.xml` +|`releng/icons` |A generator of the editor's icons, run with `java releng/icons/GenerateIcons.java` |`releng/marketplace` |Eclipse Marketplace listing content |`src/site` |This documentation site, published to GitHub Pages |`.github/workflows` |CI build, release, and site/update-site publishing From 4e5416abfbbacfaefacd739834f728a6e6681239 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Thu, 24 Sep 2026 12:56:43 -0500 Subject: [PATCH 014/170] feat(editor): Add dbUnit Dataset Editor with Source page * Add FlatXmlDatasetEditor, a multi-page editor with a Tables page (a placeholder composite for now) and a Source page over the same document, following external file changes and deletion, and keeping the global Edit menu actions routed to whichever page is active through the new DatasetEditorContributor. * Add EditorInputDtdSource, resolving a DOCTYPE's external DTD relative to the editor input's workspace file or, for inputs outside the workspace, its file store, and never reading a non-file system identifier such as an http: URL. * Bind the new editor to the flat XML content type in plugin.xml, and add UiTestWorkspace, disposable per test, to open and close real editors against workspace files. * Add the "%editor.name" and Source/Tables page message keys, and the filesystem bundle EditorInputDtdSource needs. * Declare org.eclipse.jface.text as an additional bundle, so the UI test fragment requires it, as the host does, instead of importing the org.eclipse.jface.text package, which org.eclipse.text and org.eclipse.jface.text split between them, from org.eclipse.text alone; that would hide IFindReplaceTarget from the tests. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U5nNyaSgyKqUwLFUVEGNKv --- .../META-INF/MANIFEST.MF | 1 + .../build.properties | 5 + .../plugin.properties | 1 + .../org.dbunit.eclipse.dataset.ui/plugin.xml | 12 + .../dbunit/eclipse/dataset/ui/Messages.java | 10 + .../ui/editor/DatasetEditorContributor.java | 70 ++++ .../ui/editor/EditorInputDtdSource.java | 215 +++++++++++ .../ui/editor/FlatXmlDatasetEditor.java | 348 ++++++++++++++++++ .../ui/editor/FlatXmlSourceEditor.java | 42 +++ .../eclipse/dataset/ui/editor/TablesPage.java | 55 +++ .../eclipse/dataset/ui/messages.properties | 2 + .../ui/editor/EditorInputDtdSourceTest.java | 325 ++++++++++++++++ .../ui/editor/FlatXmlDatasetEditorTest.java | 231 ++++++++++++ .../dataset/ui/editor/UiTestWorkspace.java | 117 ++++++ 14 files changed, 1434 insertions(+) create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/DatasetEditorContributor.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/EditorInputDtdSource.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/FlatXmlDatasetEditor.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/FlatXmlSourceEditor.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/TablesPage.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/EditorInputDtdSourceTest.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/FlatXmlDatasetEditorTest.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/UiTestWorkspace.java diff --git a/bundles/org.dbunit.eclipse.dataset.ui/META-INF/MANIFEST.MF b/bundles/org.dbunit.eclipse.dataset.ui/META-INF/MANIFEST.MF index bc441b1f..bd396f4a 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/META-INF/MANIFEST.MF +++ b/bundles/org.dbunit.eclipse.dataset.ui/META-INF/MANIFEST.MF @@ -10,6 +10,7 @@ Bundle-ActivationPolicy: lazy Bundle-Activator: org.dbunit.eclipse.dataset.ui.DatasetUiPlugin Automatic-Module-Name: org.dbunit.eclipse.dataset.ui Require-Bundle: org.dbunit.eclipse.dataset.core;bundle-version="[1.0.0,2.0.0)", + org.eclipse.core.filesystem;bundle-version="1.10.0", org.eclipse.core.runtime;bundle-version="3.31.0", org.eclipse.core.resources;bundle-version="3.20.0", org.eclipse.jface.text;bundle-version="3.25.0", diff --git a/bundles/org.dbunit.eclipse.dataset.ui/build.properties b/bundles/org.dbunit.eclipse.dataset.ui/build.properties index f64c0d9f..e9ca0517 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/build.properties +++ b/bundles/org.dbunit.eclipse.dataset.ui/build.properties @@ -1,5 +1,10 @@ source.. = src/main/java/ output.. = target/classes/ +# Tycho builds the UI tests into a fragment whose imports bnd computes. bnd would import each package +# that Eclipse splits between two bundles, such as org.eclipse.jface.text, from one bundle alone, which +# hides the other bundle's classes during the tests. Requiring these bundles instead, as the host does, +# gives the fragment every part of those packages. +additional.bundles = org.eclipse.jface.text bin.includes = META-INF/,\ .,\ plugin.properties,\ diff --git a/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties b/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties index b9237f8f..f6562951 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties +++ b/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties @@ -1,2 +1,3 @@ Bundle-Name = dbUnit Dataset Editor Bundle-Vendor = dbUnit +editor.name = dbUnit Dataset Editor diff --git a/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml b/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml index 60ec1316..4c86c697 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml +++ b/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml @@ -4,4 +4,16 @@ + + + + + + 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 index 684763dd..87023c48 100644 --- 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 @@ -31,6 +31,16 @@ 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; + static { NLS.initializeMessages(BUNDLE_NAME, Messages.class); 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..7e7b7e0c --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/DatasetEditorContributor.java @@ -0,0 +1,70 @@ +/* + * + * 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.eclipse.jface.action.IAction; +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 menu actions to whichever page of {@link FlatXmlDatasetEditor} is active. + * + * @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() }; + + 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 }; + + private FlatXmlDatasetEditor multiPageEditor; + + @Override + public void setActiveEditor(final IEditorPart part) + { + super.setActiveEditor(part); + multiPageEditor = (FlatXmlDatasetEditor) part; + } + + @Override + public void setActivePage(final IEditorPart activeEditor) + { + final IActionBars actionBars = getActionBars(); + for (int index = 0; index < GLOBAL_ACTION_IDS.length; index++) + { + final IAction action = activeEditor instanceof ITextEditor + ? ((ITextEditor) activeEditor).getAction(TEXT_EDITOR_ACTION_IDS[index]) : null; + actionBars.setGlobalActionHandler(GLOBAL_ACTION_IDS[index], action); + } + actionBars.updateActionBars(); + } +} 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..233b6cfc --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/EditorInputDtdSource.java @@ -0,0 +1,215 @@ +/* + * + * 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.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.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.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. + * + * @since 1.0.0 + */ +final class EditorInputDtdSource implements DtdSource +{ + private static final String XML_CONTENT_TYPE = "org.eclipse.core.runtime.xml"; + + private final IEditorInput input; + + 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 + { + return loadResolved(systemId); + } + catch (final RuntimeException e) + { + return Optional.empty(); + } + } + + private Optional loadResolved(final String systemId) + { + if (isWindowsPath(systemId)) + { + return readLocalFile(Paths.get(systemId)); + } + final URI asUri = parseUri(systemId); + if (asUri != null && asUri.isAbsolute()) + { + if (!"file".equalsIgnoreCase(asUri.getScheme())) + { + return Optional.empty(); + } + return readLocalFile(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 static 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) + { + return Optional.empty(); + } + } + + private static Optional loadUriRelative(final URI datasetUri, final String systemId) + { + final URI resolved; + try + { + resolved = datasetUri.resolve(systemId); + } + catch (final IllegalArgumentException e) + { + return Optional.empty(); + } + if (!"file".equalsIgnoreCase(resolved.getScheme())) + { + return Optional.empty(); + } + return readLocalFile(Paths.get(resolved)); + } + + private static Optional readLocalFile(final Path 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) + { + return Optional.empty(); + } + } + + /** + * 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/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..72eedab8 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/FlatXmlDatasetEditor.java @@ -0,0 +1,348 @@ +/* + * + * 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.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.dbunit.eclipse.dataset.ui.preferences.PreferenceKeys; +import org.eclipse.core.runtime.IProgressMonitor; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.IFindReplaceTarget; +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.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.IDocumentProvider; +import org.eclipse.ui.texteditor.IElementStateListener; +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 +{ + /** + * 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 IPropertyListener sourceInputListener = (source, propertyId) -> + { + if (propertyId == IEditorPart.PROP_INPUT) + { + handleInputChanged(); + } + }; + + private final IElementStateListener elementStateListener = new IElementStateListener() + { + @Override + public void elementDirtyStateChanged(final Object element, final boolean isDirty) + { + // Nothing to do: the editor only reacts to its element being deleted or moved. + } + + @Override + public void elementContentAboutToBeReplaced(final Object element) + { + // Nothing to do: the editor only reacts to its element being deleted or moved. + } + + @Override + public void elementContentReplaced(final Object element) + { + // Nothing to do: the editor only reacts to its element being deleted or moved. + } + + @Override + public void elementDeleted(final Object element) + { + if (element != null && element.equals(sourceEditor.getEditorInput())) + { + closeIfClean(); + } + } + + @Override + public void elementMoved(final Object originalElement, final Object movedElement) + { + if (movedElement == null && originalElement != null + && originalElement.equals(sourceEditor.getEditorInput())) + { + closeIfClean(); + } + } + }; + + 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) + { + if (FlatXmlDatasetEditor.this.equals(getSite().getPage().getActivePart())) + { + refreshExternalState(); + } + } + + @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 FlatXmlSourceEditor sourceEditor; + + 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(); + 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()); + datasetDocument = new FlatXmlDatasetDocument(document, + new EditorInputDtdSource(getEditorInput()), PreferenceKeys.readOptions(), + this::currentCharset); + datasetDocument.refresh(); + + tablesPage = new TablesPage(getContainer(), this, datasetDocument); + addPage(TABLES_PAGE_INDEX, tablesPage.getControl()); + setPageText(TABLES_PAGE_INDEX, Messages.Editor_tablesPageText); + + final boolean editable = datasetDocument.getModel().isEditable() || datasetDocument.isBlank(); + setActivePage(editable ? TABLES_PAGE_INDEX : SOURCE_PAGE_INDEX); + previousPageIndex = getActivePage(); + + sourceEditor.addPropertyListener(sourceInputListener); + sourceEditor.getDocumentProvider().addElementStateListener(elementStateListener); + getSite().getPage().addPartListener(partListener); + getSite().getWorkbenchWindow().getWorkbench().addWindowListener(windowListener); + } + + @Override + protected void pageChange(final int newPageIndex) + { + if (previousPageIndex == TABLES_PAGE_INDEX) + { + tablesPage.deactivate(); + } + super.pageChange(newPageIndex); + if (newPageIndex == TABLES_PAGE_INDEX) + { + datasetDocument.refresh(); + tablesPage.activate(); + } + previousPageIndex = newPageIndex; + } + + @Override + public void doSave(final IProgressMonitor monitor) + { + sourceEditor.doSave(monitor); + } + + @Override + public void doSaveAs() + { + sourceEditor.doSaveAs(); + } + + @Override + public boolean isSaveAsAllowed() + { + return true; + } + + FlatXmlSourceEditor getSourceEditor() + { + return sourceEditor; + } + + String getPageTitle(final int pageIndex) + { + return getPageText(pageIndex); + } + + @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 == IFindReplaceTarget.class) + { + return getActivePage() == SOURCE_PAGE_INDEX ? sourceEditor.getAdapter(adapterClass) : null; + } + return super.getAdapter(adapterClass); + } + + @Override + public void dispose() + { + if (sourceEditor != null) + { + sourceEditor.removePropertyListener(sourceInputListener); + final IDocumentProvider documentProvider = sourceEditor.getDocumentProvider(); + if (documentProvider != null) + { + documentProvider.removeElementStateListener(elementStateListener); + } + } + if (getSite() != null && getSite().getPage() != null) + { + getSite().getPage().removePartListener(partListener); + } + if (getSite() != null && getSite().getWorkbenchWindow() != null) + { + getSite().getWorkbenchWindow().getWorkbench().removeWindowListener(windowListener); + } + if (datasetDocument != null) + { + datasetDocument.dispose(); + } + super.dispose(); + } + + 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)); + } + + private void closeIfClean() + { + if (!isDirty()) + { + Display.getDefault() + .asyncExec(() -> getSite().getPage().closeEditor(FlatXmlDatasetEditor.this, false)); + } + } + + private void refreshExternalState() + { + sourceEditor.checkExternalModification(); + datasetDocument.reloadDtd(); + } + + Charset currentCharset() + { + 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; + } + try + { + return Charset.forName(encoding); + } + catch (final IllegalCharsetNameException | UnsupportedCharsetException e) + { + return StandardCharsets.UTF_8; + } + } +} 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..02897163 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/FlatXmlSourceEditor.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.editor; + +import org.eclipse.ui.editors.text.TextEditor; + +/** + * The Source page of {@link FlatXmlDatasetEditor}: a plain text editor over the same document, with the + * one addition the multi-page editor needs to detect changes made outside the workbench. + * + * @since 1.0.0 + */ +public class FlatXmlSourceEditor extends TextEditor +{ + /** + * 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 on the nested editor's behalf, because a nested + * editor never receives part activation notifications directly. + */ + public void checkExternalModification() + { + safelySanityCheckState(getEditorInput()); + } +} 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..1ad90768 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/TablesPage.java @@ -0,0 +1,55 @@ +/* + * + * 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.flatxml.FlatXmlDatasetDocument; +import org.eclipse.swt.SWT; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Control; + +/** + * The Tables page of {@link FlatXmlDatasetEditor}. + * + * @since 1.0.0 + */ +final class TablesPage +{ + private final Composite control; + + TablesPage(final Composite parent, final FlatXmlDatasetEditor editor, + final FlatXmlDatasetDocument datasetDocument) + { + control = new Composite(parent, SWT.NONE); + } + + Control getControl() + { + return control; + } + + void activate() + { + } + + void deactivate() + { + } +} 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 index e69de29b..7fa812da 100644 --- 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 @@ -0,0 +1,2 @@ +Editor_tablesPageText = Tables +Editor_sourcePageText = Source 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..1385e1af --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/EditorInputDtdSourceTest.java @@ -0,0 +1,325 @@ +/* + * + * 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.nio.file.Files; +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.jface.resource.ImageDescriptor; +import org.eclipse.ui.IEditorInput; +import org.eclipse.ui.IPersistableElement; +import org.junit.jupiter.api.Test; +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_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(""); + } + } + + 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/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..df65596c --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/FlatXmlDatasetEditorTest.java @@ -0,0 +1,231 @@ +/* + * + * 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.InputStream; +import java.nio.charset.StandardCharsets; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IMarker; +import org.eclipse.core.runtime.NullProgressMonitor; +import org.eclipse.jface.text.ITextSelection; +import org.eclipse.ui.IEditorInput; +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.IWorkbenchPage; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.ide.IDE; +import org.eclipse.ui.ide.IGotoMarker; +import org.eclipse.ui.texteditor.ITextEditor; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link FlatXmlDatasetEditor} against the Editor Structure lifecycle rules. + */ +class FlatXmlDatasetEditorTest +{ + private static final int TABLES_PAGE_INDEX = 0; + + private static final int SOURCE_PAGE_INDEX = 1; + + @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 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 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_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 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 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); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/UiTestWorkspace.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/UiTestWorkspace.java new file mode 100644 index 00000000..f9ef3f63 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/UiTestWorkspace.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.editor; + +import java.io.ByteArrayInputStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import org.eclipse.core.resources.IFile; +import org.eclipse.core.resources.IProject; +import org.eclipse.core.resources.ResourcesPlugin; +import org.eclipse.core.runtime.CoreException; +import org.eclipse.swt.widgets.Display; +import org.eclipse.ui.IEditorPart; +import org.eclipse.ui.IWorkbenchPage; +import org.eclipse.ui.PartInitException; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.ide.IDE; + +/** + * A disposable workspace project for editor tests: creates files, opens them, drains pending SWT events, + * then closes every editor it opened and deletes the project. + */ +final class UiTestWorkspace implements AutoCloseable +{ + private final IProject project; + + private final List openedEditors = new ArrayList<>(); + + UiTestWorkspace() throws CoreException + { + project = ResourcesPlugin.getWorkspace().getRoot() + .getProject("dataset-editor-test-" + UUID.randomUUID()); + project.create(null); + project.open(null); + } + + IFile createFile(final String name, final String content) throws CoreException + { + return createFile(name, content, StandardCharsets.UTF_8); + } + + IFile createFile(final String name, final String content, final Charset charset) throws CoreException + { + final IFile file = project.getFile(name); + file.create(new ByteArrayInputStream(content.getBytes(charset)), true, null); + return file; + } + + IEditorPart open(final IFile file) throws PartInitException + { + final IEditorPart editor = IDE.openEditor(activePage(), file); + openedEditors.add(editor); + processEvents(); + return editor; + } + + /** + * Opens a file directly in the dbUnit Dataset Editor, bypassing content-type detection: for a file, + * such as a blank one, that the editor's content type no longer claims. + */ + IEditorPart openInDatasetEditor(final IFile file) throws PartInitException + { + final IEditorPart editor = IDE.openEditor(activePage(), file, FlatXmlDatasetEditor.ID); + openedEditors.add(editor); + processEvents(); + return editor; + } + + + static void processEvents() + { + final Display display = Display.getCurrent() != null ? Display.getCurrent() : Display.getDefault(); + while (display.readAndDispatch()) + { + // Drain pending SWT events so asynchronous editor and workbench work completes. + } + } + + private static IWorkbenchPage activePage() + { + return PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage(); + } + + @Override + public void close() throws CoreException + { + final IWorkbenchPage page = activePage(); + for (final IEditorPart editor : openedEditors) + { + page.closeEditor(editor, false); + } + processEvents(); + project.delete(true, true, null); + } +} From 8340f8ccbfe15677715a7c07d1176b2bbfd72e9c Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Thu, 24 Sep 2026 13:12:26 -0500 Subject: [PATCH 015/170] feat(editor): Show one sheet tab per table * Add TablesPage: one CTabItem per table in model order, with a tooltip of its row and column counts, italic text for a table without rows, and a warning or error image when it has problems; each tab's control is a placeholder composite until the grid lands. * Reconcile tabs against the model on every change: create, move, and update tabs in model order, dispose the ones for tables that no longer exist, and keep a tab's identity across a rename recorded through expectRename. * Show ErrorBanner for a blocking problem, with a link that selects its range on the Source page, or for a read-only input; otherwise keep it hidden. * Show a blank-document state with a button that creates an empty dataset, in place of the tab folder. * Schedule a coalesced refresh when the page is active on a source change, and defer it to the next activation otherwise, so typing on the Source page never reparses on every keystroke. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr --- .../dataset/ui/editor/ErrorBanner.java | 135 +++++++ .../ui/editor/FlatXmlDatasetEditor.java | 16 + .../eclipse/dataset/ui/editor/TablesPage.java | 345 +++++++++++++++++- .../dataset/ui/editor/TablesPageTest.java | 239 ++++++++++++ .../dataset/ui/editor/UiTestWorkspace.java | 34 +- 5 files changed, 766 insertions(+), 3 deletions(-) create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/ErrorBanner.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java 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..d57911cb --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/ErrorBanner.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 org.dbunit.eclipse.dataset.core.model.DatasetProblem; +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("Show in Source"); + 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("This dataset is read-only."); + 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 index 72eedab8..25f8cef7 100644 --- 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 @@ -244,11 +244,27 @@ FlatXmlSourceEditor getSourceEditor() return sourceEditor; } + FlatXmlDatasetDocument getDatasetDocument() + { + return datasetDocument; + } + + TablesPage getTablesPage() + { + return tablesPage; + } + String getPageTitle(final int pageIndex) { return getPageText(pageIndex); } + void showOnSourcePage(final int offset, final int length) + { + setActivePage(SOURCE_PAGE_INDEX); + sourceEditor.selectAndReveal(offset, length); + } + @Override public T getAdapter(final Class adapterClass) { 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 index 1ad90768..58f936d6 100644 --- 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 @@ -20,24 +20,139 @@ */ package org.dbunit.eclipse.dataset.ui.editor; +import java.util.HashSet; +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.flatxml.FlatXmlDatasetDocument; +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.eclipse.jface.resource.FontDescriptor; +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.swt.SWT; +import org.eclipse.swt.custom.CTabFolder; +import org.eclipse.swt.custom.CTabItem; +import org.eclipse.swt.custom.StackLayout; +import org.eclipse.swt.events.SelectionListener; +import org.eclipse.swt.graphics.Font; +import org.eclipse.swt.graphics.Image; +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.ui.ISharedImages; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.texteditor.ITextEditor; /** - * The Tables page of {@link FlatXmlDatasetEditor}. + * 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 { + private final FlatXmlDatasetEditor editor; + + private final FlatXmlDatasetDocument datasetDocument; + + private final LocalResourceManager resources; + private final Composite control; + private final ErrorBanner errorBanner; + + private final Composite contentStack; + + private final StackLayout contentStackLayout; + + private final Composite blankComposite; + + private final CTabFolder tabFolder; + + private final Button createEmptyDatasetButton; + + private final Map tabsByKey = new LinkedHashMap<>(); + + private final IDocumentListener sourceDocumentListener = new IDocumentListener() + { + @Override + public void documentAboutToBeChanged(final DocumentEvent event) + { + // Nothing to do before a change: documentChanged marks the page for a refresh afterwards. + } + + @Override + public void documentChanged(final DocumentEvent event) + { + refreshPending = true; + if (active) + { + scheduleRefresh(); + } + } + }; + + private boolean active; + + private boolean refreshPending; + + private boolean refreshScheduled; + + private boolean editable; + + private String expectedRenameOldKey; + + private String expectedRenameNewKey; + TablesPage(final Composite parent, final FlatXmlDatasetEditor editor, final FlatXmlDatasetDocument datasetDocument) { + this.editor = editor; + this.datasetDocument = datasetDocument; + resources = new LocalResourceManager(JFaceResources.getResources(), parent); + control = new Composite(parent, SWT.NONE); + final GridLayout controlLayout = new GridLayout(1, false); + controlLayout.marginWidth = 0; + controlLayout.marginHeight = 0; + control.setLayout(controlLayout); + + 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); + + blankComposite = new Composite(contentStack, SWT.NONE); + blankComposite.setLayout(new GridLayout(1, false)); + final Label blankLabel = new Label(blankComposite, SWT.CENTER); + blankLabel.setText("The file is empty."); + blankLabel.setLayoutData(new GridData(SWT.CENTER, SWT.BOTTOM, true, true)); + createEmptyDatasetButton = new Button(blankComposite, SWT.PUSH); + createEmptyDatasetButton.setText("Create Empty Dataset"); + createEmptyDatasetButton.setLayoutData(new GridData(SWT.CENTER, SWT.TOP, true, true)); + createEmptyDatasetButton.addSelectionListener( + SelectionListener.widgetSelectedAdapter(event -> createEmptyDataset())); + + tabFolder = new CTabFolder(contentStack, SWT.TOP | SWT.BORDER | SWT.FLAT); + + sourceDocument().addDocumentListener(sourceDocumentListener); + datasetDocument.addModelListener(event -> reconcile()); + + reconcile(); } Control getControl() @@ -47,9 +162,237 @@ Control getControl() void activate() { + active = true; + if (refreshPending) + { + refreshPending = false; + datasetDocument.refresh(); + } } void deactivate() { + active = false; + } + + /** + * Tells the page that the table currently keyed {@code oldKey} is about to become {@code newKey}, so + * the next reconciliation keeps its tab instead of disposing and recreating it. + * + * @param oldKey The table's key before the rename. + * @param newKey The table's key after the rename. + */ + void expectRename(final String oldKey, final String newKey) + { + expectedRenameOldKey = oldKey; + expectedRenameNewKey = newKey; + } + + boolean isEditable() + { + return editable; + } + + CTabFolder getTabFolder() + { + return tabFolder; + } + + ErrorBanner getErrorBanner() + { + return errorBanner; + } + + boolean isShowingBlankState() + { + return blankComposite.equals(contentStackLayout.topControl); + } + + Button getCreateEmptyDatasetButton() + { + return createEmptyDatasetButton; + } + + private IDocument sourceDocument() + { + final ITextEditor sourceEditor = editor.getSourceEditor(); + return sourceEditor.getDocumentProvider().getDocument(sourceEditor.getEditorInput()); + } + + private void scheduleRefresh() + { + if (refreshScheduled) + { + return; + } + refreshScheduled = true; + control.getDisplay().asyncExec(() -> + { + refreshScheduled = false; + if (control.isDisposed()) + { + return; + } + if (refreshPending) + { + refreshPending = false; + datasetDocument.refresh(); + } + }); + } + + private void reconcile() + { + if (control.isDisposed()) + { + return; + } + final DatasetModel model = datasetDocument.getModel(); + final boolean inputModifiable = editor.getSourceEditor().isEditorInputModifiable(); + editable = model.isEditable() && inputModifiable; + + if (datasetDocument.isBlank()) + { + reconcileTabs(DatasetModel.EMPTY); + createEmptyDatasetButton.setEnabled(inputModifiable); + contentStackLayout.topControl = blankComposite; + } + else + { + reconcileTabs(model); + contentStackLayout.topControl = tabFolder; + } + contentStack.layout(); + + updateBanner(model); + } + + private void reconcileTabs(final DatasetModel model) + { + final Set seenKeys = new HashSet<>(); + int index = 0; + for (final DatasetTable table : model.getTables()) + { + final String key = resolveRenamedKey(table.getKey()); + if (!seenKeys.add(key)) + { + continue; + } + CTabItem item = tabsByKey.get(key); + if (item == null) + { + item = new CTabItem(tabFolder, SWT.NONE, index); + item.setControl(new Composite(tabFolder, SWT.NONE)); + tabsByKey.put(key, item); + } + else if (tabFolder.indexOf(item) != index) + { + item = moveTab(item, index); + tabsByKey.put(key, item); + } + updateTab(item, table, model); + index++; + } + expectedRenameOldKey = null; + expectedRenameNewKey = null; + + final Iterator> iterator = tabsByKey.entrySet().iterator(); + while (iterator.hasNext()) + { + final Map.Entry entry = iterator.next(); + if (!seenKeys.contains(entry.getKey())) + { + entry.getValue().getControl().dispose(); + entry.getValue().dispose(); + iterator.remove(); + } + } + } + + private String resolveRenamedKey(final String currentKey) + { + if (currentKey.equals(expectedRenameNewKey) && tabsByKey.containsKey(expectedRenameOldKey)) + { + tabsByKey.put(currentKey, tabsByKey.remove(expectedRenameOldKey)); + } + return currentKey; + } + + private CTabItem moveTab(final CTabItem oldItem, final int index) + { + final Control tabControl = oldItem.getControl(); + oldItem.setControl(null); + oldItem.dispose(); + final CTabItem newItem = new CTabItem(tabFolder, SWT.NONE, index); + newItem.setControl(tabControl); + return newItem; + } + + private void updateTab(final CTabItem item, final DatasetTable table, final DatasetModel model) + { + item.setText(table.getName()); + item.setToolTipText( + table.getRows().size() + " rows, " + table.getColumns().size() + " columns"); + item.setFont(table.getRows().isEmpty() ? italicFont() : null); + item.setImage(problemImage(model.getProblems(table.getKey()))); + } + + 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 void updateBanner(final DatasetModel model) + { + if (!editor.getSourceEditor().isEditorInputModifiable()) + { + errorBanner.showReadOnly(); + return; + } + if (datasetDocument.isBlank()) + { + errorBanner.hide(); + return; + } + for (final DatasetProblem problem : model.getProblems()) + { + if (problem.code().blocksEditing()) + { + errorBanner.showBlocking(problem); + return; + } + } + errorBanner.hide(); + } + + private void createEmptyDataset() + { + if (editor.getSourceEditor().validateEditorInputState()) + { + datasetDocument.createEmptyDataset(); + } + } + + private static Image sharedImage(final String key) + { + return PlatformUI.getWorkbench().getSharedImages().getImage(key); } } diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java new file mode 100644 index 00000000..b7b3ce9b --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java @@ -0,0 +1,239 @@ +/* + * + * 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.List; + +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.eclipse.core.resources.IFile; +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.CTabFolder; +import org.eclipse.swt.custom.CTabItem; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.widgets.Button; +import org.eclipse.swt.widgets.Composite; +import org.eclipse.swt.widgets.Event; +import org.eclipse.ui.texteditor.ITextEditor; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link TablesPage} against the Tables Page layout, reconciliation, and refresh-scheduling rules. + */ +class TablesPageTest +{ + @Test + void testTablesPage_whenOpened_showsOneTabPerTableInModelOrder() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", + ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final CTabFolder tabFolder = editor.getTablesPage().getTabFolder(); + + assertThat(tabFolder.getItemCount()).as("There must be one tab per table.").isEqualTo(2); + assertThat(tabFolder.getItem(0).getText()).as("Tabs must be in model order.") + .isEqualTo("USERS"); + assertThat(tabFolder.getItem(1).getText()).as("Tabs must be in model order.") + .isEqualTo("ORDERS"); + } + } + + @Test + void testTablesPage_whenTablesAreAddedAndRemoved_updatesTheTabs() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final TablesPage tablesPage = editor.getTablesPage(); + final FlatXmlDatasetDocument datasetDocument = editor.getDatasetDocument(); + + datasetDocument.addTable("ORDERS", List.of()); + + assertThat(tablesPage.getTabFolder().getItemCount()).as("Adding a table must add a tab.") + .isEqualTo(2); + + datasetDocument.deleteTable("USERS"); + + assertThat(tablesPage.getTabFolder().getItemCount()) + .as("Deleting a table must remove its tab.").isEqualTo(1); + assertThat(tablesPage.getTabFolder().getItem(0).getText()) + .as("The remaining table's tab must survive.").isEqualTo("ORDERS"); + } + } + + @Test + void testTablesPage_whenDtdContentModelRepeatsATableName_opensWithOneTabPerTable() 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 CTabFolder tabFolder = editor.getTablesPage().getTabFolder(); + + assertThat(tabFolder.getItemCount()) + .as("A DTD content model that repeats a table name must still open with one tab " + + "per table, not crash.") + .isEqualTo(2); + assertThat(tabFolder.getItem(0).getText()).as("B has a row, so it comes first.") + .isEqualTo("B"); + assertThat(tabFolder.getItem(1).getText()) + .as("A has no rows, so its single declared-only tab is appended last.") + .isEqualTo("A"); + } + } + + @Test + void testTablesPage_whenRenamingATableThroughExpectRename_keepsTheSameTabItem() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final TablesPage tablesPage = editor.getTablesPage(); + final CTabItem originalItem = tablesPage.getTabFolder().getItem(0); + + tablesPage.expectRename("USERS", "CUSTOMERS"); + editor.getDatasetDocument().renameTable("USERS", "CUSTOMERS"); + + assertThat(tablesPage.getTabFolder().getItemCount()).as("The rename must not add a tab.") + .isEqualTo(1); + assertThat(tablesPage.getTabFolder().getItem(0)) + .as("expectRename must keep the same tab item, not recreate it.") + .isSameAs(originalItem); + assertThat(tablesPage.getTabFolder().getItem(0).getText()) + .as("The surviving tab must show the new name.").isEqualTo("CUSTOMERS"); + } + } + + @Test + void testTablesPage_whenXmlHasABlockingError_showsTheBannerAndIsNotEditable() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("broken.xml", ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final TablesPage tablesPage = editor.getTablesPage(); + + assertThat(tablesPage.isEditable()) + .as("A blocking XML error must make the page non-editable.").isFalse(); + assertThat(tablesPage.getErrorBanner().getControl().getVisible()) + .as("A blocking XML error must show the banner.").isTrue(); + } + } + + @Test + void testTablesPage_whenFileIsBlank_showsCreateEmptyDatasetButtonThatCreatesIt() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("blank.xml", ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.openInDatasetEditor(file); + final TablesPage tablesPage = editor.getTablesPage(); + + assertThat(tablesPage.isShowingBlankState()) + .as("A blank file must show the blank-document state.").isTrue(); + + tablesPage.getCreateEmptyDatasetButton().notifyListeners(SWT.Selection, new Event()); + UiTestWorkspace.processEvents(); + + assertThat(editor.getDatasetDocument().isBlank()) + .as("Clicking Create Empty Dataset must create the dataset.").isFalse(); + assertThat(tablesPage.isShowingBlankState()) + .as("The page must switch away from the blank-document state.").isFalse(); + } + } + + @Test + void testTablesPage_whenFileIsBlank_centersTheMessageAndButtonWithoutTheBanner() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("blank.xml", ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.openInDatasetEditor(file); + final TablesPage tablesPage = editor.getTablesPage(); + final Composite page = (Composite) tablesPage.getControl(); + page.setSize(800, 600); + page.layout(true, true); + final Button button = tablesPage.getCreateEmptyDatasetButton(); + final Rectangle buttonBounds = page.getDisplay().map(button.getParent(), page, button.getBounds()); + + assertThat(tablesPage.getErrorBanner().getControl().getVisible()) + .as("A blank file must not show the error banner, as the blank state explains it.") + .isFalse(); + assertThat(Math.abs(buttonBounds.y + buttonBounds.height / 2 - 300)) + .as("The button must sit with the message at the center of the page, not at its bottom.") + .isLessThan(100); + } + } + + @Test + void testTablesPage_whenBlankFileIsReadOnly_disablesCreateEmptyDatasetAndLeavesTheFileBlank() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.openReadOnlyExternalFile(""); + final Button button = editor.getTablesPage().getCreateEmptyDatasetButton(); + + assertThat(button.getEnabled()).as("A read-only file must disable Create Empty Dataset.").isFalse(); + + button.notifyListeners(SWT.Selection, new Event()); + UiTestWorkspace.processEvents(); + + assertThat(editor.getDatasetDocument().isBlank()) + .as("Create Empty Dataset must not change a file whose input state does not validate.") + .isTrue(); + } + } + + @Test + void testRefreshScheduling_whenTablesPageIsNotActive_refreshesOnlyOnActivation() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final TablesPage tablesPage = editor.getTablesPage(); + tablesPage.deactivate(); + + final ITextEditor sourceEditor = editor.getSourceEditor(); + sourceEditor.getDocumentProvider().getDocument(sourceEditor.getEditorInput()).replace(0, 0, + ""); + UiTestWorkspace.processEvents(); + + assertThat(editor.getDatasetDocument().isStale()) + .as("A source change while the Tables page is inactive must not refresh immediately.") + .isTrue(); + + tablesPage.activate(); + + assertThat(editor.getDatasetDocument().isStale()) + .as("Activating the Tables page must refresh the stale model.").isFalse(); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/UiTestWorkspace.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/UiTestWorkspace.java index f9ef3f63..afeac010 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/UiTestWorkspace.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/UiTestWorkspace.java @@ -21,12 +21,17 @@ package org.dbunit.eclipse.dataset.ui.editor; import java.io.ByteArrayInputStream; +import java.io.IOException; import java.nio.charset.Charset; 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.UUID; +import org.eclipse.core.filesystem.EFS; +import org.eclipse.core.filesystem.IFileStore; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IProject; import org.eclipse.core.resources.ResourcesPlugin; @@ -36,11 +41,12 @@ import org.eclipse.ui.IWorkbenchPage; import org.eclipse.ui.PartInitException; import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.ide.FileStoreEditorInput; import org.eclipse.ui.ide.IDE; /** * A disposable workspace project for editor tests: creates files, opens them, drains pending SWT events, - * then closes every editor it opened and deletes the project. + * then closes every editor it opened and deletes the project and the files it created outside it. */ final class UiTestWorkspace implements AutoCloseable { @@ -48,6 +54,8 @@ final class UiTestWorkspace implements AutoCloseable private final List openedEditors = new ArrayList<>(); + private final List externalFiles = new ArrayList<>(); + UiTestWorkspace() throws CoreException { project = ResourcesPlugin.getWorkspace().getRoot() @@ -88,6 +96,23 @@ IEditorPart openInDatasetEditor(final IFile file) throws PartInitException return editor; } + /** + * Creates a read-only file outside the workspace, as the editor gets for a file that it must not change, + * and opens it in the dataset editor. + */ + IEditorPart openReadOnlyExternalFile(final String content) throws IOException, PartInitException + { + final Path file = Files.createTempFile("dataset-editor-test-", ".xml"); + externalFiles.add(file); + Files.writeString(file, content); + file.toFile().setReadOnly(); + final IFileStore fileStore = EFS.getLocalFileSystem().getStore(file.toUri()); + final IEditorPart editor = activePage().openEditor(new FileStoreEditorInput(fileStore), + FlatXmlDatasetEditor.ID); + openedEditors.add(editor); + processEvents(); + return editor; + } static void processEvents() { @@ -104,7 +129,7 @@ private static IWorkbenchPage activePage() } @Override - public void close() throws CoreException + public void close() throws CoreException, IOException { final IWorkbenchPage page = activePage(); for (final IEditorPart editor : openedEditors) @@ -113,5 +138,10 @@ public void close() throws CoreException } processEvents(); project.delete(true, true, null); + for (final Path file : externalFiles) + { + file.toFile().setWritable(true); + Files.delete(file); + } } } From 164997ea10e58b06cb0718f87b2bcc161f5dfbeb Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Thu, 24 Sep 2026 17:16:23 -0500 Subject: [PATCH 016/170] feat(grid): Display tables in a spreadsheet grid * Add DatasetGrid, assembling one NatTable per table from a body, column header, row header, and corner layer over the dataset document's current model; editing stays disabled for now. * Add TableBodyDataProvider and ColumnHeaderDataProvider, reading rows, columns, and values from the model without ever reparsing. * Add DatasetCellLabels and ColumnHeaderLabels, and GridStyleConfiguration styling a NULL cell and a pending column in italics and a column with a problem with a warning or error icon. * Add NullAwareDisplayConverter, showing the configured NULL display text for a NULL cell and a line break glyph for each line break, while keeping the underlying value untouched. * Add ColumnWidths, sizing each column from its header and the first 200 rows on first display, and remembering a column's width by key across refreshes. * Refresh a table's grid on every model change: a lightweight repaint when its row count and column keys are unchanged, otherwise a full rebuild that reapplies column widths and reselects the previous anchor, clamped to the new bounds, or a pending selection an operation requested. * Make TablesPage implement DatasetGridContext, and give each table's tab a real grid in place of its placeholder composite. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr --- .../eclipse/dataset/ui/editor/TablesPage.java | 82 +++++- .../ui/grid/ColumnHeaderDataProvider.java | 64 +++++ .../dataset/ui/grid/ColumnHeaderLabels.java | 97 +++++++ .../eclipse/dataset/ui/grid/ColumnWidths.java | 109 ++++++++ .../dataset/ui/grid/DatasetCellLabels.java | 51 ++++ .../eclipse/dataset/ui/grid/DatasetGrid.java | 254 ++++++++++++++++++ .../dataset/ui/grid/DatasetGridContext.java | 77 ++++++ .../ui/grid/GridEditConfiguration.java | 52 ++++ .../ui/grid/GridStyleConfiguration.java | 87 ++++++ .../ui/grid/NullAwareDisplayConverter.java | 58 ++++ .../dataset/ui/grid/ProblemHeaderPainter.java | 93 +++++++ .../ui/grid/TableBodyDataProvider.java | 120 +++++++++ .../dataset/ui/grid/DatasetGridTest.java | 254 ++++++++++++++++++ .../ui/grid/ProblemHeaderPainterTest.java | 230 ++++++++++++++++ 14 files changed, 1625 insertions(+), 3 deletions(-) create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ColumnHeaderDataProvider.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ColumnHeaderLabels.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ColumnWidths.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/DatasetCellLabels.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGrid.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridContext.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/GridEditConfiguration.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/GridStyleConfiguration.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/NullAwareDisplayConverter.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ProblemHeaderPainter.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/TableBodyDataProvider.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/ProblemHeaderPainterTest.java 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 index 58f936d6..5419902c 100644 --- 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 @@ -27,11 +27,18 @@ import java.util.Map; import java.util.Set; +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.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.DatasetUiPlugin; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGrid; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.preferences.PreferenceKeys; +import org.eclipse.jface.action.IMenuManager; import org.eclipse.jface.resource.FontDescriptor; import org.eclipse.jface.resource.JFaceResources; import org.eclipse.jface.resource.LocalResourceManager; @@ -43,6 +50,7 @@ import org.eclipse.swt.custom.CTabItem; import org.eclipse.swt.custom.StackLayout; import org.eclipse.swt.events.SelectionListener; +import org.eclipse.swt.graphics.Color; import org.eclipse.swt.graphics.Font; import org.eclipse.swt.graphics.Image; import org.eclipse.swt.layout.GridData; @@ -50,6 +58,7 @@ 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.Label; import org.eclipse.ui.ISharedImages; import org.eclipse.ui.PlatformUI; @@ -61,7 +70,7 @@ * * @since 1.0.0 */ -final class TablesPage +final class TablesPage implements DatasetGridContext { private final FlatXmlDatasetEditor editor; @@ -85,6 +94,8 @@ final class TablesPage private final Map tabsByKey = new LinkedHashMap<>(); + private final Map gridsByKey = new LinkedHashMap<>(); + private final IDocumentListener sourceDocumentListener = new IDocumentListener() { @Override @@ -188,11 +199,68 @@ void expectRename(final String oldKey, final String newKey) expectedRenameNewKey = newKey; } - boolean isEditable() + @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 relativeLuminance(control.getBackground()) < 0.5; + } + + @Override + public boolean executeEdit(final Runnable edit) + { + if (!editable || !editor.getSourceEditor().validateEditorInputState()) + { + return false; + } + try + { + edit.run(); + } + catch (final DatasetEditException e) + { + editor.getEditorSite().getActionBars().getStatusLineManager().setErrorMessage(e.getMessage()); + Display.getCurrent().beep(); + return false; + } + editor.getEditorSite().getActionBars().getStatusLineManager().setErrorMessage(null); + return true; + } + + @Override + public void fillContextMenu(final IMenuManager menu, final String region) + { + } + + 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); + } + CTabFolder getTabFolder() { return tabFolder; @@ -279,10 +347,13 @@ private void reconcileTabs(final DatasetModel model) continue; } CTabItem item = tabsByKey.get(key); + DatasetGrid grid = gridsByKey.get(key); if (item == null) { + grid = new DatasetGrid(tabFolder, this, key); + gridsByKey.put(key, grid); item = new CTabItem(tabFolder, SWT.NONE, index); - item.setControl(new Composite(tabFolder, SWT.NONE)); + item.setControl(grid.getControl()); tabsByKey.put(key, item); } else if (tabFolder.indexOf(item) != index) @@ -290,6 +361,7 @@ else if (tabFolder.indexOf(item) != index) item = moveTab(item, index); tabsByKey.put(key, item); } + grid.tableChanged(table); updateTab(item, table, model); index++; } @@ -305,6 +377,7 @@ else if (tabFolder.indexOf(item) != index) entry.getValue().getControl().dispose(); entry.getValue().dispose(); iterator.remove(); + gridsByKey.remove(entry.getKey()); } } } @@ -314,6 +387,9 @@ private String resolveRenamedKey(final String currentKey) if (currentKey.equals(expectedRenameNewKey) && tabsByKey.containsKey(expectedRenameOldKey)) { tabsByKey.put(currentKey, tabsByKey.remove(expectedRenameOldKey)); + final DatasetGrid grid = gridsByKey.remove(expectedRenameOldKey); + grid.tableRenamed(currentKey); + gridsByKey.put(currentKey, grid); } return currentKey; } 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..9342ca71 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ColumnHeaderLabels.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.grid; + +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.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 List problems = bodyDataProvider.getContext().getDatasetDocument().getModel() + .getProblems(bodyDataProvider.getTableKey()); + ProblemSeverity worst = null; + for (final DatasetProblem problem : problems) + { + if (!column.name().equals(problem.columnName())) + { + continue; + } + if (problem.severity() == ProblemSeverity.ERROR) + { + return ProblemSeverity.ERROR; + } + if (problem.severity() == ProblemSeverity.WARNING) + { + worst = ProblemSeverity.WARNING; + } + } + return worst; + } +} 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..94416b14 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ColumnWidths.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.grid; + +import java.util.HashMap; +import java.util.Locale; +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.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. + * + * @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<>(); + + /** + * Records the current width of every column of a table, 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++) + { + widthsByColumnKey.put(columnKey(table, index), bodyDataLayer.getColumnWidthByPosition(index)); + } + } + + /** + * 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 = columnKey(table, index); + 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 static String columnKey(final DatasetTable table, final int columnIndex) + { + return table.getColumns().get(columnIndex).name().toUpperCase(Locale.ENGLISH); + } + + private static 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.getRows().get(row).getValue(columnIndex); + if (value != null) + { + width = Math.max(width, gc.textExtent(value).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/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..058940a3 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/DatasetCellLabels.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.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"; + + private final TableBodyDataProvider bodyDataProvider; + + DatasetCellLabels(final TableBodyDataProvider bodyDataProvider) + { + this.bodyDataProvider = bodyDataProvider; + } + + @Override + public void accumulateConfigLabels(final LabelStack configLabels, final int columnPosition, + final int rowPosition) + { + if (bodyDataProvider.getDataValue(columnPosition, rowPosition) == null) + { + configLabels.addLabel(NULL_VALUE); + } + } +} 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..3da65c70 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGrid.java @@ -0,0 +1,254 @@ +/* + * + * 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.Locale; + +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +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.config.DefaultEditConfiguration; +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.selection.SelectionLayer; +import org.eclipse.nebula.widgets.nattable.style.theme.DarkNatTableThemeConfiguration; +import org.eclipse.nebula.widgets.nattable.style.theme.ModernNatTableThemeConfiguration; +import org.eclipse.nebula.widgets.nattable.viewport.ViewportLayer; +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 NatTable natTable; + + private final DatasetCellLabels cellLabels; + + private final ColumnHeaderLabels columnHeaderLabels; + + private final ColumnWidths columnWidths = new ColumnWidths(); + + private DatasetTable currentTable; + + private PositionCoordinate pendingSelection; + + /** + * 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); + 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 NatTable(parent, NatTable.DEFAULT_STYLE_OPTIONS, gridLayer, false); + natTable.addConfiguration(new DefaultNatTableStyleConfiguration()); + natTable.addConfiguration(new GridEditConfiguration(context)); + natTable.addConfiguration(new GridStyleConfiguration()); + natTable.configure(); + natTable.setTheme(context.isDarkTheme() ? new DarkNatTableThemeConfiguration() + : new ModernNatTableThemeConfiguration()); + } + + /** + * Returns the control to place in the table's tab. + * + * @return The grid's control. + */ + public Control getControl() + { + return natTable; + } + + NatTable getNatTable() + { + return natTable; + } + + TableBodyDataProvider getBodyDataProvider() + { + return bodyDataProvider; + } + + SelectionLayer getSelectionLayer() + { + return selectionLayer; + } + + 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; + } + + /** + * 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); + } + + /** + * Requests the cell to select the next time the table changes, instead of the old selection anchor + * clamped to the new bounds. + * + * @param columnIndex The column of the cell to select. + * @param rowIndex The row of the cell to select. + */ + public void setPendingSelection(final int columnIndex, final int rowIndex) + { + pendingSelection = new PositionCoordinate(selectionLayer, columnIndex, rowIndex); + } + + /** + * 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; + } + + // 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); + } + + 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) + { + pendingSelection = null; + return; + } + final PositionCoordinate target = pendingSelection != null ? pendingSelection : oldAnchor; + pendingSelection = null; + if (target == null || target.columnPosition < 0 || target.rowPosition < 0) + { + return; + } + final int column = Math.min(target.columnPosition, columnCount - 1); + final int row = Math.min(target.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 (!columnKey(oldColumns.get(index)).equals(columnKey(newColumns.get(index)))) + { + return false; + } + } + return true; + } + + private static String columnKey(final DatasetColumn column) + { + return column.name().toUpperCase(Locale.ENGLISH); + } +} 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..015880e6 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridContext.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.ui.grid; + +import org.dbunit.eclipse.dataset.core.edit.DatasetDocument; +import org.eclipse.jface.action.IMenuManager; + +/** + * 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); + + /** + * 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); +} 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..24e1aa8c --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/GridEditConfiguration.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.grid; + +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.config.IEditableRule; +import org.eclipse.nebula.widgets.nattable.edit.EditConfigAttributes; +import org.eclipse.nebula.widgets.nattable.style.DisplayMode; + +/** + * Registers how a {@link DatasetGrid}'s cells are displayed and, once editing is enabled, edited. + * + * @since 1.0.0 + */ +final class GridEditConfiguration extends AbstractRegistryConfiguration +{ + private final DatasetGridContext context; + + GridEditConfiguration(final DatasetGridContext context) + { + this.context = context; + } + + @Override + public void configureRegistry(final IConfigRegistry configRegistry) + { + configRegistry.registerConfigAttribute(EditConfigAttributes.CELL_EDITABLE_RULE, + IEditableRule.NEVER_EDITABLE); + configRegistry.registerConfigAttribute(CellConfigAttributes.DISPLAY_CONVERTER, + new NullAwareDisplayConverter(context), DisplayMode.NORMAL); + } +} 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..e47735b5 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/GridStyleConfiguration.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.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, 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 = new Style(); + nullValueStyle.setAttributeValue(CellStyleAttributes.FONT, + JFaceResources.getFontRegistry().getItalic(JFaceResources.DEFAULT_FONT)); + nullValueStyle.setAttributeValue(CellStyleAttributes.FOREGROUND_COLOR, + JFaceResources.getColorRegistry().get(JFacePreferences.QUALIFIER_COLOR)); + registerForNormalAndSelect(configRegistry, nullValueStyle, DatasetCellLabels.NULL_VALUE); + + final Style pendingColumnStyle = new Style(); + pendingColumnStyle.setAttributeValue(CellStyleAttributes.FONT, + JFaceResources.getFontRegistry().getItalic(JFaceResources.DEFAULT_FONT)); + registerForNormalAndSelect(configRegistry, pendingColumnStyle, 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 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..9823d47e --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/NullAwareDisplayConverter.java @@ -0,0 +1,58 @@ +/* + * + * 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 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 DatasetGridContext context; + + NullAwareDisplayConverter(final DatasetGridContext context) + { + this.context = context; + } + + @Override + public Object canonicalToDisplayValue(final Object canonicalValue) + { + if (canonicalValue == null) + { + return context.getNullDisplayText(); + } + 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/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/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..b8929009 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/TableBodyDataProvider.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.List; +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.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); + } + + @Override + public Object getDataValue(final int columnIndex, final int rowIndex) + { + final Optional table = table(); + if (table.isEmpty() || rowIndex < 0 || rowIndex >= table.get().getRows().size() || columnIndex < 0 + || columnIndex >= table.get().getColumns().size()) + { + return null; + } + return table.get().getRows().get(rowIndex).getValue(columnIndex); + } + + @Override + public void setDataValue(final int columnIndex, final int rowIndex, final Object newValue) + { + final Optional table = table(); + if (table.isEmpty()) + { + return; + } + final Object currentValue = getDataValue(columnIndex, rowIndex); + if (currentValue == null && "".equals(newValue)) + { + return; + } + final String columnName = table.get().getColumns().get(columnIndex).name(); + final String key = tableKey; + context.executeEdit(() -> context.getDatasetDocument().setCells(key, + List.of(new CellChange(rowIndex, columnName, (String) newValue)))); + } + + 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/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java new file mode 100644 index 00000000..b6ad393a --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java @@ -0,0 +1,254 @@ +/* + * + * 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 static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +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.edit.DatasetDocument; +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.action.IMenuManager; +import org.eclipse.jface.text.Document; +import org.eclipse.jface.text.IDocument; +import org.eclipse.nebula.widgets.nattable.config.CellConfigAttributes; +import org.eclipse.nebula.widgets.nattable.coordinate.PositionCoordinate; +import org.eclipse.nebula.widgets.nattable.data.convert.IDisplayConverter; +import org.eclipse.nebula.widgets.nattable.layer.LabelStack; +import org.eclipse.nebula.widgets.nattable.style.DisplayMode; +import org.eclipse.swt.layout.FillLayout; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link DatasetGrid} against the Grid specification's assembly, data, labels, and refresh rules. + */ +class DatasetGridTest +{ + private Shell shell; + + @BeforeEach + void createShell() + { + shell = new Shell(Display.getDefault()); + shell.setLayout(new FillLayout()); + shell.setSize(400, 300); + shell.open(); + processEvents(); + } + + private static void processEvents() + { + final Display display = Display.getCurrent(); + while (display.readAndDispatch()) + { + // Let NatTable finish laying out before commands rely on its viewport. + } + } + + @AfterEach + void disposeShell() + { + shell.dispose(); + } + + @Test + void testDatasetGrid_whenDisplayed_showsRowsAndColumnsMatchingTheTable() + { + final FlatXmlDatasetDocument datasetDocument = create("" + + ""); + final DatasetGrid grid = new DatasetGrid(shell, new TestContext(datasetDocument), "USERS"); + + final TableBodyDataProvider bodyDataProvider = grid.getBodyDataProvider(); + assertThat(bodyDataProvider.getRowCount()).as("The row count must match the table.").isEqualTo(2); + assertThat(bodyDataProvider.getColumnCount()).as("The column count must match the table.") + .isEqualTo(2); + assertThat(bodyDataProvider.getDataValue(1, 1)).as("Cell values must match the table.") + .isEqualTo("Bob"); + } + + @Test + void testDisplayConverter_whenValueIsNull_showsTheNullDisplayTextButKeepsTheDataValueNull() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final DatasetGrid grid = new DatasetGrid(shell, new TestContext(datasetDocument), "USERS"); + + assertThat(grid.getBodyDataProvider().getDataValue(1, 1)) + .as("The canonical data value of a NULL cell must stay null.").isNull(); + + final IDisplayConverter converter = normalConverter(grid); + assertThat(converter.canonicalToDisplayValue(null)) + .as("The NORMAL converter must show the configured NULL display text.") + .isEqualTo("(null)"); + } + + @Test + void testDisplayConverter_whenValueHasLineBreaks_showsTheLineBreakGlyph() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final DatasetGrid grid = new DatasetGrid(shell, new TestContext(datasetDocument), "USERS"); + + final IDisplayConverter converter = normalConverter(grid); + + assertThat(converter.canonicalToDisplayValue("line1\nline2\r\nline3\rline4")) + .as("Every line break sequence must become the line break glyph.") + .isEqualTo("line1⏎line2⏎line3⏎line4"); + } + + @Test + void testColumnHeaderLabels_whenColumnIsPending_getsThePendingColumnLabel() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + datasetDocument.addColumn("USERS", "EXTRA"); + final DatasetGrid grid = new DatasetGrid(shell, new TestContext(datasetDocument), "USERS"); + + final LabelStack idLabels = grid.columnHeaderLabelsFor(0); + final LabelStack extraLabels = grid.columnHeaderLabelsFor(1); + + assertThat(idLabels.hasLabel("PENDING_COLUMN")).as("A data column must not be pending.") + .isFalse(); + assertThat(extraLabels.hasLabel("PENDING_COLUMN")).as("A pending column must get the label.") + .isTrue(); + } + + @Test + void testTableChanged_whenStructureIsUnchanged_keepsTheSelection() + { + final FlatXmlDatasetDocument datasetDocument = create( + ""); + final DatasetGrid grid = new DatasetGrid(shell, new TestContext(datasetDocument), "USERS"); + processEvents(); + grid.getSelectionLayer().moveSelectionAnchor(1, 1); + + datasetDocument.setCells("USERS", List.of(new CellChange(1, "NAME", "Carol"))); + grid.tableChanged(datasetDocument.getModel().findTable("USERS").orElseThrow()); + + final PositionCoordinate anchor = grid.getSelectionLayer().getSelectionAnchor(); + assertThat(anchor.columnPosition).as("A structurally unchanged model must keep the selection.") + .isEqualTo(1); + assertThat(anchor.rowPosition).as("A structurally unchanged model must keep the selection.") + .isEqualTo(1); + } + + @Test + void testTableChanged_whenARowIsInserted_updatesTheRowCountAndKeepsTheAnchor() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final DatasetGrid grid = new DatasetGrid(shell, new TestContext(datasetDocument), "USERS"); + processEvents(); + grid.getSelectionLayer().setSelectedCell(0, 1); + + datasetDocument.insertRows("USERS", 2, List.of(List.of("3"))); + grid.tableChanged(datasetDocument.getModel().findTable("USERS").orElseThrow()); + + assertThat(grid.getBodyDataProvider().getRowCount()) + .as("Inserting a row must update the row count.").isEqualTo(3); + assertThat(selectedCells(grid)) + .as("The selected cell must stay selected after an insertion elsewhere.") + .containsExactly(List.of(0, 1)); + final PositionCoordinate anchor = grid.getSelectionLayer().getSelectionAnchor(); + assertThat(List.of(anchor.columnPosition, anchor.rowPosition)) + .as("The selected cell must stay the anchor after an insertion elsewhere.") + .isEqualTo(List.of(0, 1)); + } + + private static List> selectedCells(final DatasetGrid grid) + { + final List> cells = new ArrayList<>(); + for (final PositionCoordinate position : grid.getSelectionLayer().getSelectedCellPositions()) + { + cells.add(List.of(position.columnPosition, position.rowPosition)); + } + return cells; + } + + private static IDisplayConverter normalConverter(final DatasetGrid grid) + { + return grid.getNatTable().getConfigRegistry().getConfigAttribute( + CellConfigAttributes.DISPLAY_CONVERTER, DisplayMode.NORMAL); + } + + private static FlatXmlDatasetDocument create(final String content) + { + final IDocument document = new Document(content); + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(document, DtdSource.NONE, + FlatXmlOptions.DBUNIT_DEFAULTS, () -> StandardCharsets.UTF_8); + datasetDocument.refresh(); + return datasetDocument; + } + + private static final class TestContext implements DatasetGridContext + { + private final DatasetDocument datasetDocument; + + TestContext(final DatasetDocument datasetDocument) + { + this.datasetDocument = datasetDocument; + } + + @Override + public DatasetDocument getDatasetDocument() + { + return datasetDocument; + } + + @Override + public boolean isEditable() + { + return false; + } + + @Override + public String getNullDisplayText() + { + return "(null)"; + } + + @Override + public boolean isDarkTheme() + { + return false; + } + + @Override + public boolean executeEdit(final Runnable edit) + { + edit.run(); + return true; + } + + @Override + public void fillContextMenu(final IMenuManager menu, final String region) + { + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/ProblemHeaderPainterTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/ProblemHeaderPainterTest.java new file mode 100644 index 00000000..8f37b65a --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/ProblemHeaderPainterTest.java @@ -0,0 +1,230 @@ +/* + * + * 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 static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.nebula.widgets.nattable.NatTable; +import org.eclipse.nebula.widgets.nattable.config.DefaultNatTableStyleConfiguration; +import org.eclipse.nebula.widgets.nattable.data.IDataProvider; +import org.eclipse.nebula.widgets.nattable.grid.layer.DefaultGridLayer; +import org.eclipse.nebula.widgets.nattable.layer.DataLayer; +import org.eclipse.nebula.widgets.nattable.selection.command.SelectAllCommand; +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.swt.graphics.GC; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.ImageData; +import org.eclipse.swt.graphics.RGB; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.layout.FillLayout; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +/** + * Tests how the header of a column with problems is painted, by comparing a painted grid's pixels: the + * header keeps the theme's colors and shows the problem image, also while the column is selected. + */ +class ProblemHeaderPainterTest +{ + private static final int PLAIN_COLUMN = 0; + + private static final int WARNING_COLUMN = 1; + + private static final int IMAGE_SIZE = 16; + + private Shell shell; + + private NatTable natTable; + + @AfterEach + void disposeShell() + { + shell.dispose(); + } + + @Test + void testPaintCell_inTheDarkTheme_paintsTheHeaderBackgroundAsTheThemeDoes() + { + final ImageData painted = paintGrid(new DarkNatTableThemeConfiguration(), false); + + assertThat(headerPixel(painted, WARNING_COLUMN, 2)) + .as("A header with a problem must keep the dark theme's header background.") + .isEqualTo(headerPixel(painted, PLAIN_COLUMN, 2)); + assertThat(imageArea(painted, WARNING_COLUMN)) + .as("A header with a problem must show the problem image at its right edge.") + .isNotEqualTo(imageArea(painted, PLAIN_COLUMN)); + } + + @Test + void testPaintCell_whileTheColumnIsSelected_stillShowsTheProblemImage() + { + final ImageData painted = paintGrid(new ModernNatTableThemeConfiguration(), true); + + assertThat(headerPixel(painted, WARNING_COLUMN, 2)) + .as("A selected header with a problem must keep the theme's selected header background.") + .isEqualTo(headerPixel(painted, PLAIN_COLUMN, 2)); + assertThat(imageArea(painted, WARNING_COLUMN)) + .as("A selected header with a problem must still show the problem image.") + .isNotEqualTo(imageArea(painted, PLAIN_COLUMN)); + } + + /** + * Paints a grid of two columns whose second header has the warning label, as DatasetGrid configures + * its grids, and returns the painted pixels. + */ + private ImageData paintGrid(final ThemeConfiguration theme, final boolean selectAll) + { + shell = new Shell(Display.getDefault()); + shell.setLayout(new FillLayout()); + shell.setSize(400, 200); + final DefaultGridLayer gridLayer = new DefaultGridLayer(new CellValues(), new ColumnNames(), false); + ((DataLayer) gridLayer.getColumnHeaderDataLayer()).setConfigLabelAccumulator((labels, column, row) -> + { + if (column == WARNING_COLUMN) + { + labels.addLabel(ColumnHeaderLabels.COLUMN_WARNING); + } + }); + natTable = new NatTable(shell, gridLayer, false); + natTable.addConfiguration(new DefaultNatTableStyleConfiguration()); + natTable.addConfiguration(new GridStyleConfiguration()); + natTable.configure(); + natTable.setTheme(theme); + shell.open(); + final Display display = Display.getCurrent(); + while (display.readAndDispatch()) + { + // Let NatTable lay out before painting it. + } + if (selectAll) + { + natTable.doCommand(new SelectAllCommand()); + } + final Rectangle bounds = natTable.getClientArea(); + final Image image = new Image(display, bounds.width, bounds.height); + final GC gc = new GC(image); + try + { + natTable.getLayerPainter().paintLayer(natTable, gc, 0, 0, bounds, natTable.getConfigRegistry()); + return image.getImageData(); + } + finally + { + gc.dispose(); + image.dispose(); + } + } + + private RGB headerPixel(final ImageData painted, final int column, final int offsetFromLeft) + { + final int x = natTable.getStartXOfColumnPosition(column + 1) + offsetFromLeft; + return pixel(painted, x, 2); + } + + private List imageArea(final ImageData painted, final int column) + { + final int position = column + 1; + final int left = natTable.getStartXOfColumnPosition(position); + final int right = left + natTable.getColumnWidthByPosition(position); + final int top = (natTable.getRowHeightByPosition(0) - IMAGE_SIZE) / 2; + final List pixels = new ArrayList<>(); + for (int y = top; y < top + IMAGE_SIZE; y++) + { + for (int x = right - IMAGE_SIZE - 1; x < right - 1; x++) + { + pixels.add(pixel(painted, x, y)); + } + } + return pixels; + } + + /** + * Returns the painted color at a point of the grid, whose coordinates the display's zoom scales to the + * image's pixels. + */ + private RGB pixel(final ImageData painted, final int x, final int y) + { + final double zoom = painted.width / (double) natTable.getClientArea().width; + final int pixel = painted.getPixel((int) Math.round(x * zoom), (int) Math.round(y * zoom)); + return painted.palette.getRGB(pixel); + } + + private static final class CellValues implements IDataProvider + { + @Override + public Object getDataValue(final int columnIndex, final int rowIndex) + { + return "v"; + } + + @Override + public void setDataValue(final int columnIndex, final int rowIndex, final Object newValue) + { + throw new UnsupportedOperationException(); + } + + @Override + public int getColumnCount() + { + return 2; + } + + @Override + public int getRowCount() + { + return 2; + } + } + + private static final class ColumnNames implements IDataProvider + { + @Override + public Object getDataValue(final int columnIndex, final int rowIndex) + { + return "C"; + } + + @Override + public void setDataValue(final int columnIndex, final int rowIndex, final Object newValue) + { + throw new UnsupportedOperationException(); + } + + @Override + public int getColumnCount() + { + return 2; + } + + @Override + public int getRowCount() + { + return 1; + } + } +} From 39f0ad869b0875bf05c636cfcfad48aaf373f7ac Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Thu, 24 Sep 2026 18:20:43 -0500 Subject: [PATCH 017/170] feat(grid): Edit cells in place * GridEditConfiguration now registers a real CELL_EDITABLE_RULE (delegating to the context), a single-line TextCellEditor and a MultiLineTextCellEditor for the MULTI_LINE_VALUE label, an XmlCharacterValidator with a DialogErrorHandling handler, and an EditDisplayConverter for DisplayMode.EDIT. * Add XmlCharacterValidator, rejecting any code point outside the XML 1.0 Char production and naming it in U+ notation. * Add SpreadsheetEditBindings, replacing NatTable's single-click editing: F2 or a printable character reduces the selection to its anchor and edits that cell (AnchorCellKeyEditAction), and a double-click edits the clicked cell. * Add PrintableCharacterKeyEventMatcher, matching a typed character with no modifier, Shift, or AltGr. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr --- .../ui/editor/FlatXmlDatasetEditor.java | 11 + .../eclipse/dataset/ui/editor/TablesPage.java | 12 + .../ui/grid/AnchorCellKeyEditAction.java | 72 ++++ .../eclipse/dataset/ui/grid/DatasetGrid.java | 12 + .../dataset/ui/grid/EditDisplayConverter.java | 44 +++ .../ui/grid/GridEditConfiguration.java | 39 +- .../PrintableCharacterKeyEventMatcher.java | 67 ++++ .../ui/grid/SpreadsheetEditBindings.java | 58 +++ .../ui/grid/XmlCharacterValidator.java | 61 +++ .../dataset/ui/grid/DatasetGridTest.java | 34 +- .../dataset/ui/grid/GridEditingTest.java | 370 ++++++++++++++++++ 11 files changed, 774 insertions(+), 6 deletions(-) create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/AnchorCellKeyEditAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/EditDisplayConverter.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/PrintableCharacterKeyEventMatcher.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/SpreadsheetEditBindings.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/XmlCharacterValidator.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java 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 index 25f8cef7..22ce3ada 100644 --- 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 @@ -210,6 +210,7 @@ protected void pageChange(final int newPageIndex) { if (previousPageIndex == TABLES_PAGE_INDEX) { + tablesPage.commitActiveCellEditor(); tablesPage.deactivate(); } super.pageChange(newPageIndex); @@ -224,12 +225,14 @@ protected void pageChange(final int newPageIndex) @Override public void doSave(final IProgressMonitor monitor) { + commitActiveCellEditor(); sourceEditor.doSave(monitor); } @Override public void doSaveAs() { + commitActiveCellEditor(); sourceEditor.doSaveAs(); } @@ -324,6 +327,14 @@ private void handleInputChanged() datasetDocument.rebind(document, new EditorInputDtdSource(newInput)); } + private void commitActiveCellEditor() + { + if (tablesPage != null) + { + tablesPage.commitActiveCellEditor(); + } + } + private void closeIfClean() { if (!isDirty()) 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 index 5419902c..2d011152 100644 --- 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 @@ -171,6 +171,18 @@ 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. + */ + void commitActiveCellEditor() + { + for (final DatasetGrid grid : gridsByKey.values()) + { + grid.commitActiveCellEditor(); + } + } + void activate() { active = true; 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..31f6ffad --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/AnchorCellKeyEditAction.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.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 (isPrintable(event.character)) + { + natTable.doCommand( + new EditSelectionCommand(natTable, natTable.getConfigRegistry(), event.character)); + } + else + { + natTable.doCommand(new EditSelectionCommand(natTable, natTable.getConfigRegistry())); + } + } + + private static boolean isPrintable(final char character) + { + return character != 0 && !Character.isISOControl(character); + } +} 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 index 3da65c70..0945cc4f 100644 --- 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 @@ -114,6 +114,7 @@ public DatasetGrid(final Composite parent, final DatasetGridContext context, fin natTable = new NatTable(parent, NatTable.DEFAULT_STYLE_OPTIONS, gridLayer, false); natTable.addConfiguration(new DefaultNatTableStyleConfiguration()); natTable.addConfiguration(new GridEditConfiguration(context)); + natTable.addConfiguration(new SpreadsheetEditBindings(selectionLayer)); natTable.addConfiguration(new GridStyleConfiguration()); natTable.configure(); natTable.setTheme(context.isDarkTheme() ? new DarkNatTableThemeConfiguration() @@ -130,6 +131,17 @@ 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() + { + return natTable.commitAndCloseActiveCellEditor(); + } + NatTable getNatTable() { return natTable; 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 index 24e1aa8c..ecbf13f3 100644 --- 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 @@ -22,18 +22,24 @@ 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.config.IEditableRule; 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.MultiLineTextCellEditor; +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, once editing is enabled, edited. + * Registers how a {@link DatasetGrid}'s cells are displayed and edited. * * @since 1.0.0 */ final class GridEditConfiguration extends AbstractRegistryConfiguration { + private static final String MULTI_LINE_VALUE = "MULTI_LINE_VALUE"; + private final DatasetGridContext context; GridEditConfiguration(final DatasetGridContext context) @@ -44,9 +50,32 @@ final class GridEditConfiguration extends AbstractRegistryConfiguration @Override public void configureRegistry(final IConfigRegistry configRegistry) { - configRegistry.registerConfigAttribute(EditConfigAttributes.CELL_EDITABLE_RULE, - IEditableRule.NEVER_EDITABLE); + 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, + new MultiLineTextCellEditor(false), DisplayMode.EDIT, MULTI_LINE_VALUE); + configRegistry.registerConfigAttribute(EditConfigAttributes.OPEN_IN_DIALOG, Boolean.TRUE, + DisplayMode.EDIT, MULTI_LINE_VALUE); + 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, + new NullAwareDisplayConverter(context), DisplayMode.NORMAL, GridRegion.BODY); configRegistry.registerConfigAttribute(CellConfigAttributes.DISPLAY_CONVERTER, - new NullAwareDisplayConverter(context), DisplayMode.NORMAL); + new EditDisplayConverter(), DisplayMode.EDIT, GridRegion.BODY); } } 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..c1afc3b0 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/PrintableCharacterKeyEventMatcher.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.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 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; + } + + @Override + public boolean matches(final KeyEvent event) + { + if (event.character == 0 || Character.isISOControl(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/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..27890bba --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/SpreadsheetEditBindings.java @@ -0,0 +1,58 @@ +/* + * + * 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.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. + * + * @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()); + } +} 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..46b2e7cd --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/XmlCharacterValidator.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.grid; + +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 (!isXmlChar(codePoint)) + { + throw new ValidationFailedException( + String.format("Character U+%04X is not allowed in XML.", codePoint)); + } + offset += Character.charCount(codePoint); + } + return true; + } + + private static boolean isXmlChar(final int codePoint) + { + return codePoint == 0x9 || codePoint == 0xA || codePoint == 0xD + || (codePoint >= 0x20 && codePoint <= 0xD7FF) + || (codePoint >= 0xE000 && codePoint <= 0xFFFD) + || (codePoint >= 0x10000 && codePoint <= 0x10FFFF); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java index b6ad393a..dba6ebab 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java @@ -35,10 +35,14 @@ import org.eclipse.jface.action.IMenuManager; import org.eclipse.jface.text.Document; import org.eclipse.jface.text.IDocument; +import org.eclipse.nebula.widgets.nattable.NatTable; import org.eclipse.nebula.widgets.nattable.config.CellConfigAttributes; import org.eclipse.nebula.widgets.nattable.coordinate.PositionCoordinate; import org.eclipse.nebula.widgets.nattable.data.convert.IDisplayConverter; +import org.eclipse.nebula.widgets.nattable.grid.GridRegion; import org.eclipse.nebula.widgets.nattable.layer.LabelStack; +import org.eclipse.nebula.widgets.nattable.layer.cell.CellDisplayConversionUtils; +import org.eclipse.nebula.widgets.nattable.layer.cell.ILayerCell; import org.eclipse.nebula.widgets.nattable.style.DisplayMode; import org.eclipse.swt.layout.FillLayout; import org.eclipse.swt.widgets.Display; @@ -123,6 +127,23 @@ void testDisplayConverter_whenValueHasLineBreaks_showsTheLineBreakGlyph() .isEqualTo("line1⏎line2⏎line3⏎line4"); } + @Test + void testDisplayText_ofTheCornerHeadersAndBody_showsTheNullDisplayTextOnlyForNullBodyCells() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final DatasetGrid grid = new DatasetGrid(shell, new TestContext(datasetDocument), "USERS"); + shell.layout(); + processEvents(); + + final List texts = List.of(displayText(grid, 0, 0), displayText(grid, 0, 2), + displayText(grid, 1, 0), displayText(grid, 2, 2)); + + assertThat(texts).as("The corner must be blank, the headers must show row numbers and column names, " + + "and a NULL body cell must show the NULL display text.") + .containsExactly("", "2", "ID", "(null)"); + } + @Test void testColumnHeaderLabels_whenColumnIsPending_getsThePendingColumnLabel() { @@ -194,7 +215,18 @@ private static List> selectedCells(final DatasetGrid grid) private static IDisplayConverter normalConverter(final DatasetGrid grid) { return grid.getNatTable().getConfigRegistry().getConfigAttribute( - CellConfigAttributes.DISPLAY_CONVERTER, DisplayMode.NORMAL); + CellConfigAttributes.DISPLAY_CONVERTER, DisplayMode.NORMAL, GridRegion.BODY); + } + + /** + * Returns the text that NatTable's text painters show for a cell: the cell's value, converted by the + * display converter that the cell's display mode and labels select. + */ + private static String displayText(final DatasetGrid grid, final int columnPosition, final int rowPosition) + { + final NatTable natTable = grid.getNatTable(); + final ILayerCell cell = natTable.getCellByPosition(columnPosition, rowPosition); + return CellDisplayConversionUtils.convertDataType(cell, natTable.getConfigRegistry()); } private static FlatXmlDatasetDocument create(final String content) diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java new file mode 100644 index 00000000..beda4666 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java @@ -0,0 +1,370 @@ +/* + * + * 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 static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; + +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.eclipse.jface.action.IMenuManager; +import org.eclipse.jface.text.Document; +import org.eclipse.jface.text.IDocument; +import org.eclipse.nebula.widgets.nattable.NatTable; +import org.eclipse.nebula.widgets.nattable.data.validate.ValidationFailedException; +import org.eclipse.nebula.widgets.nattable.edit.command.EditSelectionCommand; +import org.eclipse.nebula.widgets.nattable.edit.editor.ICellEditor; +import org.eclipse.nebula.widgets.nattable.selection.SelectionLayer.MoveDirectionEnum; +import org.eclipse.nebula.widgets.nattable.selection.command.SelectCellCommand; +import org.eclipse.swt.SWT; +import org.eclipse.swt.events.KeyEvent; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Event; +import org.eclipse.swt.widgets.Shell; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests in-place editing against the Grid specification: {@link TableBodyDataProvider#setDataValue}, + * {@link XmlCharacterValidator}, and {@link PrintableCharacterKeyEventMatcher}. + */ +class GridEditingTest +{ + private Shell shell; + + @BeforeEach + void createShell() + { + shell = new Shell(Display.getDefault()); + } + + @AfterEach + void disposeShell() + { + shell.dispose(); + } + + @Test + void testSetDataValue_whenAValueChanges_rewritesOnlyThatAttribute() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + final TestContext context = new TestContext(datasetDocument); + final TableBodyDataProvider provider = new TableBodyDataProvider(context, "USERS"); + + provider.setDataValue(1, 0, "Carol"); + + assertThat(document.get()).as("Only the changed attribute's text must change.") + .isEqualTo(""); + } + + @Test + void testSetDataValue_whenEditingANullCellWithTheEmptyString_staysNull() + { + final String originalText = ""; + final IDocument document = new Document(originalText); + final FlatXmlDatasetDocument datasetDocument = create(document); + final TestContext context = new TestContext(datasetDocument); + final TableBodyDataProvider provider = new TableBodyDataProvider(context, "USERS"); + + provider.setDataValue(1, 1, ""); + + assertThat(provider.getDataValue(1, 1)).as("An empty edit of a NULL cell must keep it NULL.") + .isNull(); + assertThat(document.get()).as("An empty edit of a NULL cell must not change the document.") + .isEqualTo(originalText); + } + + @Test + void testSetDataValue_whenEditingANonNullCellWithTheEmptyString_storesTheEmptyString() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + final TestContext context = new TestContext(datasetDocument); + final TableBodyDataProvider provider = new TableBodyDataProvider(context, "USERS"); + + provider.setDataValue(1, 0, ""); + + assertThat(provider.getDataValue(1, 0)) + .as("An empty edit of a non-NULL cell must store the empty string, not NULL.").isEqualTo(""); + assertThat(document.get()).as("The stored empty string must appear as an empty attribute.") + .isEqualTo(""); + } + + @Test + void testSetDataValue_whenTheEditWouldEmptyARow_leavesTheDocumentUnchangedAndSetsTheStatusLineError() + { + final String originalText = ""; + final IDocument document = new Document(originalText); + final FlatXmlDatasetDocument datasetDocument = create(document); + final TestContext context = new TestContext(datasetDocument); + final TableBodyDataProvider provider = new TableBodyDataProvider(context, "USERS"); + + provider.setDataValue(0, 0, null); + + assertThat(document.get()).as("A rejected edit must leave the document unchanged.") + .isEqualTo(originalText); + assertThat(context.lastErrorMessage) + .as("A rejected edit must set the status line error message.") + .isEqualTo("This change would leave row 0 of table 'USERS' with no values."); + } + + @Test + void testSetDataValue_whenTheInputIsReadOnly_rejectsTheEdit() + { + final String originalText = ""; + final IDocument document = new Document(originalText); + final FlatXmlDatasetDocument datasetDocument = create(document); + final TestContext context = new TestContext(datasetDocument, false); + final TableBodyDataProvider provider = new TableBodyDataProvider(context, "USERS"); + + provider.setDataValue(1, 0, "Carol"); + + assertThat(document.get()).as("A read-only input must reject the edit.").isEqualTo(originalText); + } + + @Test + void testCommit_ofAnEditThatMovesTheSelection_closesTheEditorWithoutEditingTheNextCell() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + final NatTable natTable = openGrid(datasetDocument, "USERS").getNatTable(); + natTable.doCommand(new SelectCellCommand(natTable, 1, 1, false, false)); + natTable.doCommand(new EditSelectionCommand(natTable, natTable.getConfigRegistry())); + final ICellEditor cellEditor = natTable.getActiveCellEditor(); + cellEditor.setEditorValue("10"); + + cellEditor.commit(MoveDirectionEnum.DOWN); + + assertThat(natTable.getActiveCellEditor()) + .as("Committing with Enter or Tab must move the selection without editing the next cell.") + .isNull(); + assertThat(document.get()).as("The committed value must reach the document.") + .isEqualTo(""); + } + + @Test + void testCommitActiveCellEditor_whileACellIsBeingEdited_writesItsValueAndClosesTheEditor() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + final DatasetGrid grid = openGrid(datasetDocument, "USERS"); + final NatTable natTable = grid.getNatTable(); + natTable.doCommand(new SelectCellCommand(natTable, 1, 2, false, false)); + natTable.doCommand(new EditSelectionCommand(natTable, natTable.getConfigRegistry())); + natTable.getActiveCellEditor().setEditorValue("20"); + + final boolean closed = grid.commitActiveCellEditor(); + + assertThat(closed).as("Committing a valid value must close the editor.").isTrue(); + assertThat(natTable.getActiveCellEditor()).as("No cell editor may stay open.").isNull(); + assertThat(document.get()).as("The open editor's value must reach the document, as saving needs.") + .isEqualTo(""); + } + + @Test + void testValidate_whenTheValueContainsANonXmlCharacter_throwsNamingTheCharacter() + { + final XmlCharacterValidator validator = new XmlCharacterValidator(); + + assertThatThrownBy(() -> validator.validate(0, 0, "a\u0001b")) + .as("A code point outside the XML 1.0 Char production must be rejected.") + .isInstanceOf(ValidationFailedException.class) + .hasMessage("Character U+0001 is not allowed in XML."); + } + + @Test + void testMatches_onANonMacPlatform_forPrintableCharactersWithNoOrExpectedModifiers_returnsTrue() + { + final PrintableCharacterKeyEventMatcher matcher = new PrintableCharacterKeyEventMatcher(false); + + assertThat(matcher.matches(keyEvent('a', 'a', SWT.NONE))).as("A plain letter must match.").isTrue(); + assertThat(matcher.matches(keyEvent('5', '5', SWT.NONE))).as("A plain digit must match.").isTrue(); + assertThat(matcher.matches(keyEvent('.', '.', SWT.NONE))).as("Plain punctuation must match.") + .isTrue(); + assertThat(matcher.matches(keyEvent(' ', ' ', SWT.NONE))).as("A plain space must match.").isTrue(); + assertThat(matcher.matches(keyEvent('A', 'a', SWT.SHIFT))).as("Shift plus a letter must match.") + .isTrue(); + assertThat(matcher.matches(keyEvent('a', 'a', SWT.MOD1 | SWT.MOD3))) + .as("AltGr (Ctrl+Alt) plus a character must match.").isTrue(); + assertThat(matcher.matches(keyEvent('A', 'a', SWT.MOD1 | SWT.MOD3 | SWT.SHIFT))) + .as("AltGr with Shift plus a character must match.").isTrue(); + } + + @Test + void testMatches_onANonMacPlatform_forAltAlone_returnsFalse() + { + final PrintableCharacterKeyEventMatcher matcher = new PrintableCharacterKeyEventMatcher(false); + + assertThat(matcher.matches(keyEvent('a', 'a', SWT.ALT))) + .as("Alt alone must not match outside macOS, since it drives menu mnemonics.").isFalse(); + assertThat(matcher.matches(keyEvent('A', 'a', SWT.ALT | SWT.SHIFT))) + .as("Alt with Shift must not match outside macOS either.").isFalse(); + } + + @Test + void testMatches_onMacOs_forPrintableCharactersTypedWithOption_returnsTrue() + { + final PrintableCharacterKeyEventMatcher matcher = new PrintableCharacterKeyEventMatcher(true); + + assertThat(matcher.matches(keyEvent('a', 'a', SWT.NONE))).as("A plain letter must match.").isTrue(); + assertThat(matcher.matches(keyEvent('A', 'a', SWT.SHIFT))).as("Shift plus a letter must match.") + .isTrue(); + assertThat(matcher.matches(keyEvent('@', 'l', SWT.ALT))) + .as("A character typed with Option alone must match on macOS.").isTrue(); + assertThat(matcher.matches(keyEvent('€', 'e', SWT.ALT | SWT.SHIFT))) + .as("A character typed with Option and Shift must match on macOS.").isTrue(); + } + + @Test + void testMatches_onMacOs_forCommandModifiedCharacters_returnsFalse() + { + final PrintableCharacterKeyEventMatcher matcher = new PrintableCharacterKeyEventMatcher(true); + + assertThat(matcher.matches(keyEvent('a', 'a', SWT.COMMAND))).as("Command alone must not match.") + .isFalse(); + assertThat(matcher.matches(keyEvent('a', 'a', SWT.COMMAND | SWT.ALT))) + .as("Command plus Option must not match, even though SWT.MOD1 | SWT.MOD3 is Command " + + "plus Option on macOS.") + .isFalse(); + } + + @Test + void testMatches_forControlCharactersOrNonTypingKeys_returnsFalse() + { + final PrintableCharacterKeyEventMatcher matcher = new PrintableCharacterKeyEventMatcher(false); + + assertThat(matcher.matches(keyEvent((char) 3, 'c', SWT.MOD1))).as("Ctrl+C must not match.") + .isFalse(); + assertThat(matcher.matches(keyEvent((char) 0, SWT.ARROW_RIGHT, SWT.NONE))) + .as("An arrow key must not match.").isFalse(); + assertThat(matcher.matches(keyEvent((char) 0, SWT.F2, SWT.NONE))) + .as("A function key must not match.").isFalse(); + } + + private KeyEvent keyEvent(final char character, final int keyCode, final int stateMask) + { + final Event event = new Event(); + event.widget = shell; + event.character = character; + event.keyCode = keyCode; + event.stateMask = stateMask; + return new KeyEvent(event); + } + + private DatasetGrid openGrid(final FlatXmlDatasetDocument datasetDocument, final String tableKey) + { + shell.setSize(400, 300); + final DatasetGrid grid = new DatasetGrid(shell, new TestContext(datasetDocument), tableKey); + grid.tableChanged(datasetDocument.getModel().findTable(tableKey).orElseThrow()); + grid.getControl().setBounds(shell.getClientArea()); + shell.open(); + final Display display = Display.getCurrent(); + while (display.readAndDispatch()) + { + // Let NatTable lay out before the test edits its cells. + } + return grid; + } + + private static FlatXmlDatasetDocument create(final IDocument document) + { + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(document, DtdSource.NONE, + FlatXmlOptions.DBUNIT_DEFAULTS, () -> StandardCharsets.UTF_8); + datasetDocument.refresh(); + return datasetDocument; + } + + private static final class TestContext implements DatasetGridContext + { + private final DatasetDocument datasetDocument; + + private final boolean editable; + + private String lastErrorMessage; + + TestContext(final DatasetDocument datasetDocument) + { + this(datasetDocument, true); + } + + TestContext(final DatasetDocument datasetDocument, final boolean editable) + { + this.datasetDocument = datasetDocument; + this.editable = editable; + } + + @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(); + lastErrorMessage = null; + return true; + } + catch (final DatasetEditException e) + { + lastErrorMessage = e.getMessage(); + return false; + } + } + + @Override + public void fillContextMenu(final IMenuManager menu, final String region) + { + } + } +} From 6e7041bf3c4bd8e07061a5da751dfd10a6df7001 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Thu, 24 Sep 2026 19:07:59 -0500 Subject: [PATCH 018/170] feat(editor): Undo and redo from the Tables page * Add DocumentUndoAction, calling undo() and redo() on the document's shared IDocumentUndoManager and tracking enablement from undoable() and redoable(); it declines to run while a grid cell editor is active, since an undo or a row operation would otherwise move the rows under the open editor. * TablesPage creates one DocumentUndoAction per direction, updates their enablement on every source document change and on page activation, and exposes them through getGlobalActionHandler(String) for the UNDO and REDO action ids. * DatasetEditorContributor routes the global UNDO and REDO handlers (and, for now, a null FIND handler) to the Tables page's actions while it is active, guarding against the page-change callback arriving before the contributor's editor reference is set. * Import org.eclipse.text.undo instead of requiring the whole org.eclipse.text bundle, which also exports a split copy of org.eclipse.jface.text that the bundle already gets from the dedicated org.eclipse.jface.text bundle. * Declare org.eclipse.core.runtime as an additional bundle too, so the UI test fragment requires it instead of importing the org.eclipse.core.runtime package from org.eclipse.equinox.common alone, which would hide the ILog that DocumentUndoAction logs failures through. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U5nNyaSgyKqUwLFUVEGNKv --- .../META-INF/MANIFEST.MF | 3 +- .../build.properties | 3 +- .../ui/editor/DatasetEditorContributor.java | 41 ++++- .../dataset/ui/editor/DocumentUndoAction.java | 108 ++++++++++++ .../ui/editor/FlatXmlDatasetEditor.java | 10 ++ .../eclipse/dataset/ui/editor/TablesPage.java | 114 ++++++++++++- .../editor/DatasetEditorContributorTest.java | 135 +++++++++++++++ .../ui/editor/DocumentUndoActionTest.java | 95 +++++++++++ .../dataset/ui/editor/TablesPageTest.java | 159 ++++++++++++++++++ 9 files changed, 661 insertions(+), 7 deletions(-) create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/DocumentUndoAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/DatasetEditorContributorTest.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/DocumentUndoActionTest.java diff --git a/bundles/org.dbunit.eclipse.dataset.ui/META-INF/MANIFEST.MF b/bundles/org.dbunit.eclipse.dataset.ui/META-INF/MANIFEST.MF index bd396f4a..25ae2672 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/META-INF/MANIFEST.MF +++ b/bundles/org.dbunit.eclipse.dataset.ui/META-INF/MANIFEST.MF @@ -19,5 +19,6 @@ Require-Bundle: org.dbunit.eclipse.dataset.core;bundle-version="[1.0.0,2.0.0)", org.eclipse.ui.ide;bundle-version="3.22.0", org.eclipse.ui.workbench.texteditor;bundle-version="3.17.0", org.eclipse.nebula.widgets.nattable.core;bundle-version="[2.7.0,3.0.0)" -Import-Package: org.eclipse.osgi.util;version="1.1.0" +Import-Package: org.eclipse.osgi.util;version="1.1.0", + org.eclipse.text.undo Export-Package: org.dbunit.eclipse.dataset.ui;x-internal:=true diff --git a/bundles/org.dbunit.eclipse.dataset.ui/build.properties b/bundles/org.dbunit.eclipse.dataset.ui/build.properties index e9ca0517..18888a74 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/build.properties +++ b/bundles/org.dbunit.eclipse.dataset.ui/build.properties @@ -4,7 +4,8 @@ output.. = target/classes/ # that Eclipse splits between two bundles, such as org.eclipse.jface.text, from one bundle alone, which # hides the other bundle's classes during the tests. Requiring these bundles instead, as the host does, # gives the fragment every part of those packages. -additional.bundles = org.eclipse.jface.text +additional.bundles = org.eclipse.jface.text,\ + org.eclipse.core.runtime bin.includes = META-INF/,\ .,\ plugin.properties,\ 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 index 7e7b7e0c..bd69dec9 100644 --- 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 @@ -48,22 +48,55 @@ public final class DatasetEditorContributor extends MultiPageEditorActionBarCont private FlatXmlDatasetEditor multiPageEditor; + /** + * 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); - multiPageEditor = (FlatXmlDatasetEditor) part; } + /** + * 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. 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 IActionBars actionBars = getActionBars(); + final boolean sourcePageActive = multiPageEditor.isSourcePageActive(); + final ITextEditor sourceEditor = multiPageEditor.getSourceEditor(); + final TablesPage tablesPage = multiPageEditor.getTablesPage(); for (int index = 0; index < GLOBAL_ACTION_IDS.length; index++) { - final IAction action = activeEditor instanceof ITextEditor - ? ((ITextEditor) activeEditor).getAction(TEXT_EDITOR_ACTION_IDS[index]) : null; - actionBars.setGlobalActionHandler(GLOBAL_ACTION_IDS[index], action); + final String globalActionId = GLOBAL_ACTION_IDS[index]; + final IAction action; + if (sourcePageActive) + { + action = sourceEditor.getAction(TEXT_EDITOR_ACTION_IDS[index]); + } + else if (ActionFactory.FIND.getId().equals(globalActionId)) + { + action = null; + } + else + { + action = tablesPage.getGlobalActionHandler(globalActionId); + } + actionBars.setGlobalActionHandler(globalActionId, action); } actionBars.updateActionBars(); } diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/DocumentUndoAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/DocumentUndoAction.java new file mode 100644 index 00000000..d3928f37 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/DocumentUndoAction.java @@ -0,0 +1,108 @@ +/* + * + * 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.eclipse.core.commands.ExecutionException; +import org.eclipse.core.runtime.ILog; +import org.eclipse.jface.action.Action; +import org.eclipse.jface.text.IDocument; +import org.eclipse.text.undo.DocumentUndoManagerRegistry; +import org.eclipse.text.undo.IDocumentUndoManager; + +/** + * Undoes or redoes a document's shared undo history, so the Tables page and the Source page act on the + * same history; does nothing while a cell editor is active. + * + * @since 1.0.0 + */ +final class DocumentUndoAction extends Action +{ + private static final ILog LOG = ILog.of(DocumentUndoAction.class); + + private final Supplier document; + + private final boolean redo; + + private final BooleanSupplier hasActiveCellEditor; + + /** + * Creates an undo or a redo action for the editor's current document. + * + * @param document Returns the document whose shared undo history to act on, which changes when the + * editor's input does. + * @param redo True for redo, false for undo. + * @param hasActiveCellEditor Returns true while a grid cell editor is open, so the action can decline + * to run. + */ + DocumentUndoAction(final Supplier document, final boolean redo, + final BooleanSupplier hasActiveCellEditor) + { + super(redo ? "Redo" : "Undo"); + this.document = document; + this.redo = redo; + this.hasActiveCellEditor = hasActiveCellEditor; + update(); + } + + @Override + public void run() + { + if (hasActiveCellEditor.getAsBoolean()) + { + return; + } + final IDocumentUndoManager manager = + DocumentUndoManagerRegistry.getDocumentUndoManager(document.get()); + if (manager == null) + { + return; + } + try + { + if (redo) + { + manager.redo(); + } + else + { + manager.undo(); + } + } + catch (final ExecutionException e) + { + LOG.error((redo ? "Redo" : "Undo") + " failed.", e); + } + } + + /** + * Refreshes enablement from the document's current undo history. + */ + void update() + { + final IDocumentUndoManager manager = + DocumentUndoManagerRegistry.getDocumentUndoManager(document.get()); + final boolean enabled = manager != null && (redo ? manager.redoable() : manager.undoable()); + setEnabled(enabled); + } +} 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 index 22ce3ada..189b53f3 100644 --- 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 @@ -262,6 +262,11 @@ String getPageTitle(final int pageIndex) return getPageText(pageIndex); } + boolean isSourcePageActive() + { + return getActivePage() == SOURCE_PAGE_INDEX; + } + void showOnSourcePage(final int offset, final int length) { setActivePage(SOURCE_PAGE_INDEX); @@ -315,6 +320,10 @@ public void dispose() { datasetDocument.dispose(); } + if (tablesPage != null) + { + tablesPage.dispose(); + } super.dispose(); } @@ -325,6 +334,7 @@ private void handleInputChanged() setPartName(newInput.getName()); final IDocument document = sourceEditor.getDocumentProvider().getDocument(newInput); datasetDocument.rebind(document, new EditorInputDtdSource(newInput)); + tablesPage.inputChanged(); } private void commitActiveCellEditor() 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 index 2d011152..193703ec 100644 --- 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 @@ -38,6 +38,10 @@ import org.dbunit.eclipse.dataset.ui.grid.DatasetGrid; import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; import org.dbunit.eclipse.dataset.ui.preferences.PreferenceKeys; +import org.eclipse.core.commands.operations.IOperationHistoryListener; +import org.eclipse.core.commands.operations.OperationHistoryEvent; +import org.eclipse.core.commands.operations.OperationHistoryFactory; +import org.eclipse.jface.action.IAction; import org.eclipse.jface.action.IMenuManager; import org.eclipse.jface.resource.FontDescriptor; import org.eclipse.jface.resource.JFaceResources; @@ -45,6 +49,7 @@ import org.eclipse.jface.text.DocumentEvent; import org.eclipse.jface.text.IDocument; import org.eclipse.jface.text.IDocumentListener; +import org.eclipse.nebula.widgets.nattable.NatTable; import org.eclipse.swt.SWT; import org.eclipse.swt.custom.CTabFolder; import org.eclipse.swt.custom.CTabItem; @@ -60,8 +65,11 @@ import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Label; +import org.eclipse.text.undo.DocumentUndoManagerRegistry; +import org.eclipse.text.undo.IDocumentUndoManager; import org.eclipse.ui.ISharedImages; import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.actions.ActionFactory; import org.eclipse.ui.texteditor.ITextEditor; /** @@ -115,12 +123,20 @@ public void documentChanged(final DocumentEvent event) } }; + private final DocumentUndoAction undoAction; + + private final DocumentUndoAction redoAction; + + private final IOperationHistoryListener operationHistoryListener; + private boolean active; private boolean refreshPending; private boolean refreshScheduled; + private IDocument listenedDocument; + private boolean editable; private String expectedRenameOldKey; @@ -160,8 +176,14 @@ public void documentChanged(final DocumentEvent event) tabFolder = new CTabFolder(contentStack, SWT.TOP | SWT.BORDER | SWT.FLAT); - sourceDocument().addDocumentListener(sourceDocumentListener); + final IDocument document = sourceDocument(); + listenedDocument = document; + document.addDocumentListener(sourceDocumentListener); datasetDocument.addModelListener(event -> reconcile()); + undoAction = new DocumentUndoAction(this::sourceDocument, false, this::hasActiveCellEditor); + redoAction = new DocumentUndoAction(this::sourceDocument, true, this::hasActiveCellEditor); + operationHistoryListener = this::handleOperationHistoryEvent; + OperationHistoryFactory.getOperationHistory().addOperationHistoryListener(operationHistoryListener); reconcile(); } @@ -186,6 +208,7 @@ void commitActiveCellEditor() void activate() { active = true; + updateUndoRedoActions(); if (refreshPending) { refreshPending = false; @@ -198,6 +221,46 @@ void deactivate() active = false; } + /** + * 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); + updateUndoRedoActions(); + } + + /** + * Stops listening to the document, which can outlive the editor when another editor shares it. + */ + void dispose() + { + listenedDocument.removeDocumentListener(sourceDocumentListener); + OperationHistoryFactory.getOperationHistory().removeOperationHistoryListener(operationHistoryListener); + } + + /** + * 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) + { + if (ActionFactory.UNDO.getId().equals(actionDefinitionId)) + { + return undoAction; + } + if (ActionFactory.REDO.getId().equals(actionDefinitionId)) + { + return redoAction; + } + return null; + } + /** * Tells the page that the table currently keyed {@code oldKey} is about to become {@code newKey}, so * the next reconciliation keeps its tab instead of disposing and recreating it. @@ -299,6 +362,55 @@ private IDocument sourceDocument() return sourceEditor.getDocumentProvider().getDocument(sourceEditor.getEditorInput()); } + private void updateUndoRedoActions() + { + undoAction.update(); + redoAction.update(); + } + + private void handleOperationHistoryEvent(final OperationHistoryEvent event) + { + if (!isUndoRedoEnablementEvent(event.getEventType())) + { + return; + } + final IDocumentUndoManager manager = DocumentUndoManagerRegistry.getDocumentUndoManager(sourceDocument()); + if (manager == null || !event.getOperation().hasContext(manager.getUndoContext())) + { + return; + } + if (Display.getCurrent() == null) + { + control.getDisplay().asyncExec(() -> + { + if (!control.isDisposed()) + { + updateUndoRedoActions(); + } + }); + } + else + { + updateUndoRedoActions(); + } + } + + private static boolean isUndoRedoEnablementEvent(final int eventType) + { + return eventType == OperationHistoryEvent.DONE || eventType == OperationHistoryEvent.UNDONE + || eventType == OperationHistoryEvent.REDONE + || eventType == OperationHistoryEvent.OPERATION_ADDED + || eventType == OperationHistoryEvent.OPERATION_REMOVED + || eventType == OperationHistoryEvent.OPERATION_CHANGED; + } + + private boolean hasActiveCellEditor() + { + final CTabItem selected = tabFolder.getSelection(); + return selected != null && selected.getControl() instanceof NatTable + && ((NatTable) selected.getControl()).getActiveCellEditor() != null; + } + private void scheduleRefresh() { if (refreshScheduled) 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..38707fc4 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/DatasetEditorContributorTest.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 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.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 actions of the active dataset + * editor's active page. + */ +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)); + } + } + + 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/DocumentUndoActionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/DocumentUndoActionTest.java new file mode 100644 index 00000000..b1eed23b --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/DocumentUndoActionTest.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.editor; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.eclipse.jface.text.BadLocationException; +import org.eclipse.jface.text.Document; +import org.eclipse.jface.text.IDocument; +import org.eclipse.text.undo.DocumentUndoManagerRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link DocumentUndoAction} against the Undo and Redo specification's enablement and + * active-cell-editor rules. + */ +class DocumentUndoActionTest +{ + private IDocument document; + + @BeforeEach + void connectDocument() + { + document = new Document("Alice"); + DocumentUndoManagerRegistry.connect(document); + DocumentUndoManagerRegistry.getDocumentUndoManager(document).connect(this); + } + + @AfterEach + void disconnectDocument() + { + DocumentUndoManagerRegistry.getDocumentUndoManager(document).disconnect(this); + DocumentUndoManagerRegistry.disconnect(document); + } + + @Test + void testIsEnabled_beforeAnyEdit_followsUndoableAndRedoable() + { + final DocumentUndoAction undo = new DocumentUndoAction(() -> document, false, () -> false); + final DocumentUndoAction redo = new DocumentUndoAction(() -> document, true, () -> false); + + assertThat(undo.isEnabled()).as("There is nothing to undo before any edit.").isFalse(); + assertThat(redo.isEnabled()).as("There is nothing to redo before any edit.").isFalse(); + } + + @Test + void testRun_afterAnEdit_undoesItAndBecomesRedoable() throws BadLocationException + { + document.replace(0, 5, "Carol"); + final DocumentUndoAction undo = new DocumentUndoAction(() -> document, false, () -> false); + + assertThat(undo.isEnabled()).as("An edit must make undo enabled.").isTrue(); + undo.run(); + + assertThat(document.get()).as("Undo must restore the previous text.").isEqualTo("Alice"); + + final DocumentUndoAction redo = new DocumentUndoAction(() -> document, true, () -> false); + assertThat(redo.isEnabled()).as("Undoing must make redo enabled.").isTrue(); + redo.run(); + + assertThat(document.get()).as("Redo must reapply the edit.").isEqualTo("Carol"); + } + + @Test + void testRun_whileACellEditorIsActive_changesNothing() throws BadLocationException + { + document.replace(0, 5, "Carol"); + final DocumentUndoAction undo = new DocumentUndoAction(() -> document, false, () -> true); + + undo.run(); + + assertThat(document.get()).as("Undo must do nothing while a cell editor is active.") + .isEqualTo("Carol"); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java index b7b3ce9b..20fd5672 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java @@ -24,8 +24,11 @@ import java.util.List; +import org.dbunit.eclipse.dataset.core.edit.CellChange; import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; import org.eclipse.core.resources.IFile; +import org.eclipse.jface.action.IAction; +import org.eclipse.jface.text.IDocument; import org.eclipse.swt.SWT; import org.eclipse.swt.custom.CTabFolder; import org.eclipse.swt.custom.CTabItem; @@ -33,7 +36,12 @@ import org.eclipse.swt.widgets.Button; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Event; +import org.eclipse.ui.IEditorInput; +import org.eclipse.ui.IEditorPart; +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; /** @@ -236,4 +244,155 @@ void testRefreshScheduling_whenTablesPageIsNotActive_refreshesOnlyOnActivation() .as("Activating the Tables page must refresh the stale model.").isFalse(); } } + + @Test + void testGlobalActionHandler_undoAfterAGridEdit_restoresTheTextAndRedoReappliesIt() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final String originalText = ""; + final IFile file = workspace.createFile("dataset.xml", originalText); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final TablesPage tablesPage = editor.getTablesPage(); + final FlatXmlDatasetDocument datasetDocument = editor.getDatasetDocument(); + final IDocument document = sourceDocument(editor); + + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", "Carol"))); + UiTestWorkspace.processEvents(); + + tablesPage.getGlobalActionHandler(ActionFactory.UNDO.getId()).run(); + UiTestWorkspace.processEvents(); + + assertThat(document.get()).as("Undo must restore the original text.") + .isEqualTo(originalText); + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows().get(0) + .getValue(1)).as("Undo must restore the grid's model value.").isEqualTo("Alice"); + + tablesPage.getGlobalActionHandler(ActionFactory.REDO.getId()).run(); + UiTestWorkspace.processEvents(); + + assertThat(document.get()).as("Redo must reapply the edit.") + .isEqualTo(""); + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows().get(0) + .getValue(1)).as("Redo must restore the grid's model value.").isEqualTo("Carol"); + } + } + + @Test + void testGlobalActionHandler_undoFromTheSourcePage_undoesATablesPageEditTooBecauseHistoryIsShared() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final String originalText = ""; + final IFile file = workspace.createFile("dataset.xml", originalText); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final FlatXmlDatasetDocument datasetDocument = editor.getDatasetDocument(); + final ITextEditor sourceEditor = editor.getSourceEditor(); + final IDocument document = sourceDocument(editor); + + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", "Carol"))); + UiTestWorkspace.processEvents(); + + sourceEditor.getAction(ITextEditorActionConstants.UNDO).run(); + UiTestWorkspace.processEvents(); + + assertThat(document.get()) + .as("The Source page's undo action must undo a Tables-page edit: they share one history.") + .isEqualTo(originalText); + } + } + + @Test + void testGlobalActionHandler_afterUndoThenRedoOfAGridEdit_updatesEnablementAtEachStep() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final String originalText = ""; + final IFile file = workspace.createFile("dataset.xml", originalText); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final TablesPage tablesPage = editor.getTablesPage(); + final FlatXmlDatasetDocument datasetDocument = editor.getDatasetDocument(); + final IAction undo = tablesPage.getGlobalActionHandler(ActionFactory.UNDO.getId()); + final IAction redo = tablesPage.getGlobalActionHandler(ActionFactory.REDO.getId()); + + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", "Carol"))); + UiTestWorkspace.processEvents(); + undo.run(); + UiTestWorkspace.processEvents(); + + assertThat(redo.isEnabled()).as("Redo must become enabled right after Undo runs.").isTrue(); + + redo.run(); + UiTestWorkspace.processEvents(); + + assertThat(undo.isEnabled()).as("Undo must become enabled right after Redo runs.").isTrue(); + } + } + + @Test + void testGlobalActionHandler_afterAddColumnThenItsUndo_updatesEnablementAtEachStep() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final TablesPage tablesPage = editor.getTablesPage(); + final FlatXmlDatasetDocument datasetDocument = editor.getDatasetDocument(); + final IAction undo = tablesPage.getGlobalActionHandler(ActionFactory.UNDO.getId()); + final IAction redo = tablesPage.getGlobalActionHandler(ActionFactory.REDO.getId()); + + datasetDocument.addColumn("USERS", "EXTRA"); + UiTestWorkspace.processEvents(); + + assertThat(undo.isEnabled()) + .as("Adding a pending column must enable Undo even though it changes no text.").isTrue(); + + undo.run(); + UiTestWorkspace.processEvents(); + + assertThat(redo.isEnabled()) + .as("Undoing a pending-column operation must enable Redo.").isTrue(); + } + } + + @Test + void testTablesPage_afterTheEditorInputChanges_undoesAndRefreshesWithTheNewDocument() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final String originalText = ""; + final IEditorPart otherEditor = + workspace.open(workspace.createFile("saved-as.xml", originalText)); + final IEditorInput savedAsInput = otherEditor.getEditorInput(); + PlatformUI.getWorkbench().getActiveWorkbenchWindow().getActivePage() + .closeEditor(otherEditor, false); + final FlatXmlDatasetEditor editor = + (FlatXmlDatasetEditor) workspace.open(workspace.createFile("dataset.xml", originalText)); + final TablesPage tablesPage = editor.getTablesPage(); + final FlatXmlDatasetDocument datasetDocument = editor.getDatasetDocument(); + + editor.getSourceEditor().setInput(savedAsInput); + UiTestWorkspace.processEvents(); + final IDocument newDocument = sourceDocument(editor); + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", "Carol"))); + tablesPage.getGlobalActionHandler(ActionFactory.UNDO.getId()).run(); + + assertThat(newDocument.get()).as("Undo on the Tables page must undo the new document's edit.") + .isEqualTo(originalText); + + newDocument.replace(newDocument.get().indexOf("Alice"), "Alice".length(), "Dave"); + UiTestWorkspace.processEvents(); + + assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows().get(0) + .getValue(1)).as("A change to the new document must refresh the Tables page.") + .isEqualTo("Dave"); + } + } + + private static IDocument sourceDocument(final FlatXmlDatasetEditor editor) + { + final ITextEditor sourceEditor = editor.getSourceEditor(); + return sourceEditor.getDocumentProvider().getDocument(sourceEditor.getEditorInput()); + } } From d72a68d98ee5ea2f4343b1e286e16c2bbeaf7766 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Thu, 24 Sep 2026 23:37:49 -0500 Subject: [PATCH 019/170] feat(grid): Add grid commands for inserting and deleting rows * Add DatasetCommandIds and GridAction, a JFace Action base class that reaches the active grid through a DatasetGridContext, updates its enablement from a GridSelection, and declines to run while a grid cell editor is active. * Add InsertRowAboveAction, InsertRowBelowAction, and DeleteRowsAction, each selecting the row left at the same position afterwards. * Add GridSelection, a snapshot of a grid's selection computed from SelectionLayer's row and column position ranges rather than getSelectedCellPositions(), whose cost grows with the number of selected cells. * DatasetGridContext grows hasActiveCellEditor(), getSelection(), and setPendingSelection(int, int), so actions can reach the active grid's state without depending on NatTable types directly. * TablesPage activates the tablesPageContext and each action's handler through the editor site's IContextService and IHandlerService while it is the active page, deactivating both when the Source page takes over; it also selects the first tab by default so a freshly opened dataset is immediately editable. * DatasetGrid wires one context menu per grid, filled by the region under the cursor when the menu is about to show, and TablesPage puts Insert Row Below and Delete Rows on the tab folder's toolbar. * Declare the tablesPageContext context, the dbUnit Dataset command category, and the three commands and key bindings in plugin.xml. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr --- .../META-INF/MANIFEST.MF | 1 + .../plugin.properties | 6 + .../org.dbunit.eclipse.dataset.ui/plugin.xml | 30 ++ .../dataset/ui/actions/DatasetCommandIds.java | 53 ++++ .../dataset/ui/actions/DeleteRowsAction.java | 66 ++++ .../dataset/ui/actions/GridAction.java | 84 +++++ .../ui/actions/InsertRowAboveAction.java | 74 +++++ .../ui/actions/InsertRowBelowAction.java | 74 +++++ .../eclipse/dataset/ui/editor/TablesPage.java | 151 ++++++++- .../dataset/ui/grid/ContextMenuTarget.java | 125 ++++++++ .../eclipse/dataset/ui/grid/DatasetGrid.java | 43 +++ .../dataset/ui/grid/DatasetGridContext.java | 33 ++ .../dataset/ui/grid/GridSelection.java | 110 +++++++ .../ui/grid/SpreadsheetEditBindings.java | 11 +- .../dataset/ui/actions/GridActionsTest.java | 295 ++++++++++++++++++ .../dataset/ui/editor/TablesPageTest.java | 32 ++ .../ui/grid/ContextMenuTargetTest.java | 245 +++++++++++++++ .../dataset/ui/grid/DatasetGridTest.java | 24 ++ .../dataset/ui/grid/GridEditingTest.java | 23 ++ .../dataset/ui/grid/GridSelectionTest.java | 103 ++++++ 20 files changed, 1579 insertions(+), 4 deletions(-) create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DatasetCommandIds.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DeleteRowsAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/GridAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/InsertRowAboveAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/InsertRowBelowAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ContextMenuTarget.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/GridSelection.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/ContextMenuTargetTest.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridSelectionTest.java diff --git a/bundles/org.dbunit.eclipse.dataset.ui/META-INF/MANIFEST.MF b/bundles/org.dbunit.eclipse.dataset.ui/META-INF/MANIFEST.MF index 25ae2672..53f7d076 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/META-INF/MANIFEST.MF +++ b/bundles/org.dbunit.eclipse.dataset.ui/META-INF/MANIFEST.MF @@ -15,6 +15,7 @@ Require-Bundle: org.dbunit.eclipse.dataset.core;bundle-version="[1.0.0,2.0.0)", org.eclipse.core.resources;bundle-version="3.20.0", org.eclipse.jface.text;bundle-version="3.25.0", org.eclipse.ui;bundle-version="3.206.0", + org.eclipse.ui.workbench;bundle-version="3.132.0", org.eclipse.ui.editors;bundle-version="3.17.0", org.eclipse.ui.ide;bundle-version="3.22.0", org.eclipse.ui.workbench.texteditor;bundle-version="3.17.0", diff --git a/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties b/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties index f6562951..3098fe0d 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties +++ b/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties @@ -1,3 +1,9 @@ Bundle-Name = dbUnit Dataset Editor Bundle-Vendor = dbUnit editor.name = dbUnit Dataset Editor +context.tablesPage.name = dbUnit Dataset Tables Page +context.tablesPage.description = Active while the Tables page of the dbUnit Dataset Editor is showing. +command.category.name = dbUnit Dataset +command.insertRowAbove.name = Insert Row Above +command.insertRowBelow.name = Insert Row Below +command.deleteRows.name = Delete Rows diff --git a/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml b/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml index 4c86c697..7de72146 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml +++ b/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml @@ -16,4 +16,34 @@ + + + + + + + + + + + + + + + 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..3490a687 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DatasetCommandIds.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; + +/** + * 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"; + + private DatasetCommandIds() + { + } +} 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..e26223b4 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DeleteRowsAction.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.actions; + +import org.dbunit.eclipse.dataset.ui.DatasetImages; +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("Delete Rows"); + setImageDescriptor(DatasetImages.getImageDescriptor(DatasetImages.IMG_DELETE_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 columnIndex = Math.max(selection.anchorColumnIndex(), 0); + final int rowIndex = selection.firstRowIndex(); + final boolean applied = context.executeMultiCellEdit("Delete Rows", + () -> context.getDatasetDocument().deleteRows(selection.tableKey(), rowIndexes)); + if (applied) + { + context.setPendingSelection(columnIndex, rowIndex); + } + } + + @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/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..30738ef9 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/GridAction.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 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 does nothing while a cell editor is active. + * + * @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); + } + + @Override + public final void run() + { + if (context.hasActiveCellEditor()) + { + return; + } + runOnGrid(context); + } + + /** + * 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) + { + setEnabled(context.isEditable() && !context.hasActiveCellEditor() && 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, on a page that is already known to be editable + * with no active cell editor. + * + * @param selection The active grid's current selection. + * @return True when this command should be enabled. + */ + protected abstract boolean isEnabledFor(GridSelection selection); +} 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..cd6238a4 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/InsertRowAboveAction.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.actions; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.dbunit.eclipse.dataset.ui.DatasetImages; +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. + * + * @since 1.0.0 + */ +public final class InsertRowAboveAction extends GridAction +{ + /** + * 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("Insert Row Above"); + setImageDescriptor(DatasetImages.getImageDescriptor(DatasetImages.IMG_INSERT_ROW_ABOVE)); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final int rowIndex = Math.max(selection.anchorRowIndex(), 0); + final int columnIndex = Math.max(selection.anchorColumnIndex(), 0); + final List blankRow = new ArrayList<>(Collections.nCopies(selection.columnCount(), null)); + if (!blankRow.isEmpty()) + { + blankRow.set(0, ""); + } + final boolean applied = context.executeEdit(() -> context.getDatasetDocument() + .insertRows(selection.tableKey(), rowIndex, List.of(blankRow))); + if (applied) + { + context.setPendingSelection(columnIndex, rowIndex); + } + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return selection.tableKey() != null && selection.columnCount() > 0; + } +} 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..56270c98 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/InsertRowBelowAction.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.actions; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.dbunit.eclipse.dataset.ui.DatasetImages; +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. + * + * @since 1.0.0 + */ +public final class InsertRowBelowAction extends GridAction +{ + /** + * 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("Insert Row Below"); + setImageDescriptor(DatasetImages.getImageDescriptor(DatasetImages.IMG_INSERT_ROW_BELOW)); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final int rowIndex = Math.max(selection.anchorRowIndex() + 1, 0); + final int columnIndex = Math.max(selection.anchorColumnIndex(), 0); + final List blankRow = new ArrayList<>(Collections.nCopies(selection.columnCount(), null)); + if (!blankRow.isEmpty()) + { + blankRow.set(0, ""); + } + final boolean applied = context.executeEdit(() -> context.getDatasetDocument() + .insertRows(selection.tableKey(), rowIndex, List.of(blankRow))); + if (applied) + { + context.setPendingSelection(columnIndex, rowIndex); + } + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + return selection.tableKey() != null && selection.columnCount() > 0; + } +} 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 index 193703ec..8ec4748f 100644 --- 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 @@ -20,6 +20,7 @@ */ package org.dbunit.eclipse.dataset.ui.editor; +import java.util.ArrayList; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedHashMap; @@ -35,14 +36,22 @@ import org.dbunit.eclipse.dataset.core.model.DatasetTable; import org.dbunit.eclipse.dataset.core.model.ProblemSeverity; import org.dbunit.eclipse.dataset.ui.DatasetUiPlugin; +import org.dbunit.eclipse.dataset.ui.actions.DeleteRowsAction; +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.grid.DatasetGrid; 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.core.commands.operations.IOperationHistoryListener; import org.eclipse.core.commands.operations.OperationHistoryEvent; import org.eclipse.core.commands.operations.OperationHistoryFactory; import org.eclipse.jface.action.IAction; import org.eclipse.jface.action.IMenuManager; +import org.eclipse.jface.action.ToolBarManager; +import org.eclipse.jface.commands.ActionHandler; +import org.eclipse.jface.dialogs.MessageDialog; import org.eclipse.jface.resource.FontDescriptor; import org.eclipse.jface.resource.JFaceResources; import org.eclipse.jface.resource.LocalResourceManager; @@ -50,6 +59,7 @@ import org.eclipse.jface.text.IDocument; import org.eclipse.jface.text.IDocumentListener; import org.eclipse.nebula.widgets.nattable.NatTable; +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; @@ -65,11 +75,16 @@ import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.ToolBar; import org.eclipse.text.undo.DocumentUndoManagerRegistry; import org.eclipse.text.undo.IDocumentUndoManager; import org.eclipse.ui.ISharedImages; import org.eclipse.ui.PlatformUI; 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.texteditor.ITextEditor; /** @@ -80,6 +95,8 @@ */ final class TablesPage implements DatasetGridContext { + private static final String TABLES_PAGE_CONTEXT_ID = "org.dbunit.eclipse.dataset.ui.tablesPageContext"; + private final FlatXmlDatasetEditor editor; private final FlatXmlDatasetDocument datasetDocument; @@ -129,6 +146,18 @@ public void documentChanged(final DocumentEvent event) private final IOperationHistoryListener operationHistoryListener; + private final InsertRowAboveAction insertRowAboveAction; + + private final InsertRowBelowAction insertRowBelowAction; + + private final DeleteRowsAction deleteRowsAction; + + private final List gridActions = new ArrayList<>(); + + private final List handlerActivations = new ArrayList<>(); + + private IContextActivation contextActivation; + private boolean active; private boolean refreshPending; @@ -175,6 +204,21 @@ public void documentChanged(final DocumentEvent event) SelectionListener.widgetSelectedAdapter(event -> createEmptyDataset())); tabFolder = new CTabFolder(contentStack, SWT.TOP | SWT.BORDER | SWT.FLAT); + tabFolder.addSelectionListener( + SelectionListener.widgetSelectedAdapter(event -> updateGridActionsEnablement())); + + insertRowAboveAction = new InsertRowAboveAction(this); + insertRowBelowAction = new InsertRowBelowAction(this); + deleteRowsAction = new DeleteRowsAction(this); + gridActions.add(insertRowAboveAction); + gridActions.add(insertRowBelowAction); + gridActions.add(deleteRowsAction); + + final ToolBarManager toolBarManager = new ToolBarManager(SWT.FLAT); + toolBarManager.add(insertRowBelowAction); + toolBarManager.add(deleteRowsAction); + final ToolBar toolBar = toolBarManager.createControl(tabFolder); + tabFolder.setTopRight(toolBar); final IDocument document = sourceDocument(); listenedDocument = document; @@ -208,7 +252,16 @@ void commitActiveCellEditor() void activate() { active = true; + final IContextService contextService = editor.getEditorSite().getService(IContextService.class); + contextActivation = contextService.activateContext(TABLES_PAGE_CONTEXT_ID); + final IHandlerService handlerService = editor.getEditorSite().getService(IHandlerService.class); + for (final GridAction action : gridActions) + { + handlerActivations.add(handlerService.activateHandler(action.getActionDefinitionId(), + new ActionHandler(action))); + } updateUndoRedoActions(); + updateGridActionsEnablement(); if (refreshPending) { refreshPending = false; @@ -219,6 +272,12 @@ void activate() void deactivate() { active = false; + final IContextService contextService = editor.getEditorSite().getService(IContextService.class); + contextService.deactivateContext(contextActivation); + contextActivation = null; + final IHandlerService handlerService = editor.getEditorSite().getService(IHandlerService.class); + handlerService.deactivateHandlers(handlerActivations); + handlerActivations.clear(); } /** @@ -319,9 +378,60 @@ public boolean executeEdit(final Runnable edit) return true; } + @Override + public boolean executeMultiCellEdit(final String title, final Runnable edit) + { + if (!editable || !editor.getSourceEditor().validateEditorInputState()) + { + return false; + } + try + { + edit.run(); + } + catch (final DatasetEditException e) + { + MessageDialog.openError(control.getShell(), title, e.getMessage()); + return false; + } + editor.getEditorSite().getActionBars().getStatusLineManager().setErrorMessage(null); + return true; + } + @Override public void fillContextMenu(final IMenuManager menu, final String region) { + if (GridRegion.BODY.equals(region) || GridRegion.ROW_HEADER.equals(region)) + { + menu.add(insertRowAboveAction); + menu.add(insertRowBelowAction); + menu.add(deleteRowsAction); + } + } + + @Override + public boolean hasActiveCellEditor() + { + final CTabItem selected = tabFolder.getSelection(); + return selected != null && selected.getControl() instanceof NatTable + && ((NatTable) selected.getControl()).getActiveCellEditor() != null; + } + + @Override + public GridSelection getSelection() + { + final DatasetGrid grid = activeGrid(); + return grid != null ? grid.getSelection() : GridSelection.NONE; + } + + @Override + public void setPendingSelection(final int columnIndex, final int rowIndex) + { + final DatasetGrid grid = activeGrid(); + if (grid != null) + { + grid.setPendingSelection(columnIndex, rowIndex); + } } private static double relativeLuminance(final Color color) @@ -404,11 +514,30 @@ private static boolean isUndoRedoEnablementEvent(final int eventType) || eventType == OperationHistoryEvent.OPERATION_CHANGED; } - private boolean hasActiveCellEditor() + private void updateGridActionsEnablement() + { + final GridSelection selection = getSelection(); + for (final GridAction action : gridActions) + { + action.update(selection); + } + } + + private DatasetGrid activeGrid() { final CTabItem selected = tabFolder.getSelection(); - return selected != null && selected.getControl() instanceof NatTable - && ((NatTable) selected.getControl()).getActiveCellEditor() != null; + if (selected == null) + { + return null; + } + for (final DatasetGrid grid : gridsByKey.values()) + { + if (grid.getControl().equals(selected.getControl())) + { + return grid; + } + } + return null; } private void scheduleRefresh() @@ -457,6 +586,7 @@ private void reconcile() contentStack.layout(); updateBanner(model); + updateGridActionsEnablement(); } private void reconcileTabs(final DatasetModel model) @@ -475,6 +605,7 @@ private void reconcileTabs(final DatasetModel model) if (item == null) { grid = new DatasetGrid(tabFolder, this, key); + grid.addSelectionListener(this::updateGridActionsEnablement); gridsByKey.put(key, grid); item = new CTabItem(tabFolder, SWT.NONE, index); item.setControl(grid.getControl()); @@ -492,6 +623,12 @@ else if (tabFolder.indexOf(item) != index) expectedRenameOldKey = null; expectedRenameNewKey = null; + disposeStaleTabs(seenKeys); + selectFirstTabIfNoneSelected(); + } + + private void disposeStaleTabs(final Set seenKeys) + { final Iterator> iterator = tabsByKey.entrySet().iterator(); while (iterator.hasNext()) { @@ -506,6 +643,14 @@ else if (tabFolder.indexOf(item) != index) } } + private void selectFirstTabIfNoneSelected() + { + if (tabFolder.getSelection() == null && tabFolder.getItemCount() > 0) + { + tabFolder.setSelection(0); + } + } + private String resolveRenamedKey(final String currentKey) { if (currentKey.equals(expectedRenameNewKey) && tabsByKey.containsKey(expectedRenameOldKey)) 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..0bcb3e4e --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ContextMenuTarget.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.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 {@link GridRegion#ROW_HEADER}, {@link GridRegion#COLUMN_HEADER}, or {@link GridRegion#CORNER} + * for a point in one of them, else {@link GridRegion#BODY}, also 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/DatasetGrid.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGrid.java index 0945cc4f..ad5c93fe 100644 --- 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 @@ -25,6 +25,7 @@ import org.dbunit.eclipse.dataset.core.model.DatasetColumn; import org.dbunit.eclipse.dataset.core.model.DatasetTable; +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; @@ -41,9 +42,11 @@ import org.eclipse.nebula.widgets.nattable.layer.DataLayer; import org.eclipse.nebula.widgets.nattable.layer.LabelStack; import org.eclipse.nebula.widgets.nattable.selection.SelectionLayer; +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.viewport.ViewportLayer; +import org.eclipse.swt.graphics.Point; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; @@ -119,6 +122,19 @@ public DatasetGrid(final Composite parent, final DatasetGridContext context, fin natTable.configure(); natTable.setTheme(context.isDarkTheme() ? new DarkNatTableThemeConfiguration() : new ModernNatTableThemeConfiguration()); + wireContextMenu(context); + } + + private void wireContextMenu(final DatasetGridContext context) + { + final MenuManager menuManager = new MenuManager(); + menuManager.setRemoveAllWhenShown(true); + menuManager.addMenuListener(manager -> + { + final Point cursor = natTable.toControl(natTable.getDisplay().getCursorLocation()); + context.fillContextMenu(manager, ContextMenuTarget.regionAt(natTable, cursor.x, cursor.y)); + }); + natTable.setMenu(menuManager.createContextMenu(natTable)); } /** @@ -157,6 +173,33 @@ SelectionLayer getSelectionLayer() return selectionLayer; } + /** + * Returns this grid's current selection. + * + * @return The selection snapshot. + */ + public GridSelection getSelection() + { + return GridSelection.compute(bodyDataProvider.getTableKey(), bodyDataProvider.getColumnCount(), + selectionLayer); + } + + /** + * 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(); + } + }); + } + LabelStack cellLabelsFor(final int columnIndex, final int rowIndex) { final LabelStack labels = new LabelStack(); 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 index 015880e6..b0c62235 100644 --- 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 @@ -67,6 +67,17 @@ public interface DatasetGridContext */ 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. * @@ -74,4 +85,26 @@ public interface DatasetGridContext * @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(); + + /** + * Requests the cell to select on the active grid the next time it refreshes. + * + * @param columnIndex The column of the cell to select. + * @param rowIndex The row of the cell to select. + */ + void setPendingSelection(int columnIndex, int rowIndex); } 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..76eca384 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/GridSelection.java @@ -0,0 +1,110 @@ +/* + * + * 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 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 firstRowIndex The lowest selected row index, or -1 when {@code rowIndexes} is empty. + * @param lastRowIndex The highest selected row index, or -1 when {@code rowIndexes} is empty. + * @param firstColumnIndex The lowest selected column index, or -1 when {@code columnIndexes} is empty. + * @param lastColumnIndex The highest selected column index, or -1 when {@code columnIndexes} is empty. + * @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 columnCount, int anchorColumnIndex, int anchorRowIndex, + List rowIndexes, List columnIndexes, int firstRowIndex, int lastRowIndex, + int firstColumnIndex, int lastColumnIndex, 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, SelectionLayer.NO_SELECTION, + SelectionLayer.NO_SELECTION, List.of(), List.of(), -1, -1, -1, -1, false); + + /** + * Computes a selection snapshot from a grid's current selection. + * + * @param tableKey The key of the table the grid displays. + * @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 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, columnCount, anchor.columnPosition, anchor.rowPosition, + rowIndexes, columnIndexes, boundStart(rowIndexes), boundEnd(rowIndexes), + boundStart(columnIndexes), boundEnd(columnIndexes), wholeRowsSelected); + } + + 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/SpreadsheetEditBindings.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/SpreadsheetEditBindings.java index 27890bba..cd3c3389 100644 --- 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 @@ -28,11 +28,13 @@ 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. + * 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 */ @@ -54,5 +56,12 @@ public void configureUiBindings(final UiBindingRegistry uiBindingRegistry) 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/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java new file mode 100644 index 00000000..f46f6473 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java @@ -0,0 +1,295 @@ +/* + * + * 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.ArrayList; +import java.util.List; + +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.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.core.commands.ExecutionException; +import org.eclipse.jface.action.IMenuManager; +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 InsertRowAboveAction}, {@link InsertRowBelowAction}, and {@link DeleteRowsAction} against + * the Commands specification's insert index, selection, enablement, and active-cell-editor rules. + */ +class GridActionsTest +{ + @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.pendingSelectionRow).as("Insert Row Below must select the new row at index 2.") + .isEqualTo(2); + assertThat(context.pendingSelectionColumn).as("Insert Row Below must keep the anchor column.") + .isEqualTo(0); + } + + @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 testInsertRowAbove_withAnchorInTheFirstRow_getsTheEmptyStringInItsFirstColumn() + { + 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("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 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); + } + } + + @Test + void testUpdate_reflectsWhetherThePageIsEditable() + { + 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(); + } + + @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); + } + + private static FlatXmlDatasetDocument create(final String content) + { + return create(new Document(content)); + } + + private static FlatXmlDatasetDocument create(final IDocument document) + { + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(document, DtdSource.NONE, + FlatXmlOptions.DBUNIT_DEFAULTS, () -> StandardCharsets.UTF_8); + datasetDocument.refresh(); + return datasetDocument; + } + + private static final class TestContext implements DatasetGridContext + { + private final DatasetDocument datasetDocument; + + private final String tableKey; + + private boolean editable = true; + + private boolean hasActiveCellEditor; + + private final List multiCellEditTitles = new ArrayList<>(); + + private int anchorColumnIndex = -1; + + private int anchorRowIndex = -1; + + private List rowIndexes = List.of(); + + private int pendingSelectionColumn = -1; + + private int pendingSelectionRow = -1; + + 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() + { + final int columnCount = + datasetDocument.getModel().findTable(tableKey).orElseThrow().getColumns().size(); + final int firstRow = rowIndexes.isEmpty() ? -1 : rowIndexes.get(0); + final int lastRow = rowIndexes.isEmpty() ? -1 : rowIndexes.get(rowIndexes.size() - 1); + return new GridSelection(tableKey, columnCount, anchorColumnIndex, anchorRowIndex, rowIndexes, + List.of(), firstRow, lastRow, -1, -1, !rowIndexes.isEmpty()); + } + + @Override + public void setPendingSelection(final int columnIndex, final int rowIndex) + { + pendingSelectionColumn = columnIndex; + pendingSelectionRow = rowIndex; + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java index 20fd5672..fba943a0 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java @@ -21,11 +21,13 @@ 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.util.List; import org.dbunit.eclipse.dataset.core.edit.CellChange; import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.ui.actions.DatasetCommandIds; import org.eclipse.core.resources.IFile; import org.eclipse.jface.action.IAction; import org.eclipse.jface.text.IDocument; @@ -40,6 +42,8 @@ import org.eclipse.ui.IEditorPart; import org.eclipse.ui.PlatformUI; import org.eclipse.ui.actions.ActionFactory; +import org.eclipse.ui.contexts.IContextService; +import org.eclipse.ui.handlers.IHandlerService; import org.eclipse.ui.texteditor.ITextEditor; import org.eclipse.ui.texteditor.ITextEditorActionConstants; import org.junit.jupiter.api.Test; @@ -245,6 +249,34 @@ void testRefreshScheduling_whenTablesPageIsNotActive_refreshesOnlyOnActivation() } } + @Test + void testActivate_whenTheTablesPageIsActive_activatesTheContextAndHandlers() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final IContextService contextService = + editor.getEditorSite().getService(IContextService.class); + final IHandlerService handlerService = + editor.getEditorSite().getService(IHandlerService.class); + + assertThat(contextService.getActiveContextIds()) + .as("Opening on the Tables page must activate its context.") + .contains("org.dbunit.eclipse.dataset.ui.tablesPageContext"); + assertThatCode(() -> handlerService.executeCommand(DatasetCommandIds.INSERT_ROW_ABOVE, null)) + .as("The Tables page's command handlers must be active.") + .doesNotThrowAnyException(); + + editor.showOnSourcePage(0, 0); + UiTestWorkspace.processEvents(); + + assertThat(contextService.getActiveContextIds()) + .as("Switching to the Source page must deactivate the Tables page's context.") + .doesNotContain("org.dbunit.eclipse.dataset.ui.tablesPageContext"); + } + } + @Test void testGlobalActionHandler_undoAfterAGridEdit_restoresTheTextAndRedoReappliesIt() throws Exception { diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/ContextMenuTargetTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/ContextMenuTargetTest.java new file mode 100644 index 00000000..bfe8d75e --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/ContextMenuTargetTest.java @@ -0,0 +1,245 @@ +/* + * + * 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 static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.nebula.widgets.nattable.NatTable; +import org.eclipse.nebula.widgets.nattable.coordinate.PositionCoordinate; +import org.eclipse.nebula.widgets.nattable.data.IDataProvider; +import org.eclipse.nebula.widgets.nattable.grid.GridRegion; +import org.eclipse.nebula.widgets.nattable.grid.layer.DefaultGridLayer; +import org.eclipse.nebula.widgets.nattable.selection.SelectionLayer; +import org.eclipse.nebula.widgets.nattable.selection.command.SelectRegionCommand; +import org.eclipse.swt.events.MouseEvent; +import org.eclipse.swt.graphics.Rectangle; +import org.eclipse.swt.layout.FillLayout; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Event; +import org.eclipse.swt.widgets.Shell; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests that {@link ContextMenuTarget} selects what a right-click hits unless it is already selected, and + * finds the region of any point of a grid, also beyond its cells. + */ +class ContextMenuTargetTest +{ + private static final int COLUMNS = 3; + + private static final int ROWS = 4; + + private Shell shell; + + private NatTable natTable; + + private SelectionLayer selectionLayer; + + @BeforeEach + void createGrid() + { + shell = new Shell(Display.getDefault()); + shell.setLayout(new FillLayout()); + shell.setSize(600, 400); + final DefaultGridLayer gridLayer = new DefaultGridLayer(new CellNames(), new ColumnNames()); + selectionLayer = gridLayer.getBodyLayer().getSelectionLayer(); + natTable = new NatTable(shell, gridLayer); + shell.open(); + final Display display = Display.getCurrent(); + while (display.readAndDispatch()) + { + // Let NatTable lay out, so that points map to cells. + } + } + + @AfterEach + void disposeShell() + { + shell.dispose(); + } + + @Test + void testRun_onACellOutsideTheSelection_selectsOnlyThatCell() + { + selectionLayer.setSelectedCell(1, 1); + + new ContextMenuTarget(selectionLayer).run(natTable, rightClick(cellX(0), cellY(2))); + + assertThat(selectedCells()).as("A right-click must select the cell it hits.") + .containsExactly(List.of(0, 2)); + } + + @Test + void testRun_onACellInsideTheSelection_keepsTheSelection() + { + selectionLayer.doCommand(new SelectRegionCommand(selectionLayer, 0, 1, COLUMNS, 2, false, false)); + final List> selectionBefore = selectedCells(); + + new ContextMenuTarget(selectionLayer).run(natTable, rightClick(cellX(1), cellY(2))); + + assertThat(selectedCells()) + .as("A right-click inside the selection must keep it, for a command to act on all of it.") + .isEqualTo(selectionBefore); + } + + @Test + void testRun_onAColumnHeader_selectsThatColumn() + { + selectionLayer.setSelectedCell(0, 0); + + new ContextMenuTarget(selectionLayer).run(natTable, rightClick(cellX(2), headerY())); + + assertThat(selectionLayer.isColumnPositionFullySelected(2)) + .as("A right-click on a column header must select that column.").isTrue(); + assertThat(selectionLayer.getSelectionAnchor().columnPosition) + .as("The column must hold the selection anchor, which the column commands act on.") + .isEqualTo(2); + } + + @Test + void testRun_onARowHeader_selectsThatRow() + { + selectionLayer.setSelectedCell(0, 0); + + new ContextMenuTarget(selectionLayer).run(natTable, rightClick(headerX(), cellY(3))); + + assertThat(selectionLayer.getFullySelectedRowPositions()) + .as("A right-click on a row header must select that row instead of the previous selection.") + .containsExactly(3); + } + + @Test + void testRegionAt_ofEachPartOfTheGrid_namesItsRegionAndTheBodyBeyondTheCells() + { + final Rectangle clientArea = natTable.getClientArea(); + + final List regions = List.of(ContextMenuTarget.regionAt(natTable, cellX(1), cellY(1)), + ContextMenuTarget.regionAt(natTable, cellX(1), headerY()), + ContextMenuTarget.regionAt(natTable, headerX(), cellY(1)), + ContextMenuTarget.regionAt(natTable, clientArea.width - 2, clientArea.height - 2)); + + assertThat(regions) + .as("Each point must name its region, and the empty area beyond the cells the body.") + .containsExactly(GridRegion.BODY, GridRegion.COLUMN_HEADER, GridRegion.ROW_HEADER, + GridRegion.BODY); + } + + private int cellX(final int column) + { + final int position = column + 1; + return natTable.getStartXOfColumnPosition(position) + natTable.getColumnWidthByPosition(position) / 2; + } + + private int cellY(final int row) + { + final int position = row + 1; + return natTable.getStartYOfRowPosition(position) + natTable.getRowHeightByPosition(position) / 2; + } + + private int headerX() + { + return natTable.getColumnWidthByPosition(0) / 2; + } + + private int headerY() + { + return natTable.getRowHeightByPosition(0) / 2; + } + + private MouseEvent rightClick(final int x, final int y) + { + final Event event = new Event(); + event.widget = natTable; + event.x = x; + event.y = y; + event.button = 3; + return new MouseEvent(event); + } + + private List> selectedCells() + { + final List> cells = new ArrayList<>(); + for (final PositionCoordinate position : selectionLayer.getSelectedCellPositions()) + { + cells.add(List.of(position.columnPosition, position.rowPosition)); + } + return cells; + } + + private static final class CellNames implements IDataProvider + { + @Override + public Object getDataValue(final int columnIndex, final int rowIndex) + { + return columnIndex + "," + rowIndex; + } + + @Override + public void setDataValue(final int columnIndex, final int rowIndex, final Object newValue) + { + throw new UnsupportedOperationException(); + } + + @Override + public int getColumnCount() + { + return COLUMNS; + } + + @Override + public int getRowCount() + { + return ROWS; + } + } + + private static final class ColumnNames implements IDataProvider + { + @Override + public Object getDataValue(final int columnIndex, final int rowIndex) + { + return "C" + columnIndex; + } + + @Override + public void setDataValue(final int columnIndex, final int rowIndex, final Object newValue) + { + throw new UnsupportedOperationException(); + } + + @Override + public int getColumnCount() + { + return COLUMNS; + } + + @Override + public int getRowCount() + { + return 1; + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java index dba6ebab..08fb1d31 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java @@ -278,9 +278,33 @@ public boolean executeEdit(final Runnable edit) return true; } + @Override + public boolean executeMultiCellEdit(final String title, final Runnable edit) + { + edit.run(); + return true; + } + @Override public void fillContextMenu(final IMenuManager menu, final String region) { } + + @Override + public boolean hasActiveCellEditor() + { + return false; + } + + @Override + public GridSelection getSelection() + { + return GridSelection.NONE; + } + + @Override + public void setPendingSelection(final int columnIndex, final int rowIndex) + { + } } } diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java index beda4666..85f1d312 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java @@ -362,9 +362,32 @@ public boolean executeEdit(final Runnable edit) } } + @Override + public boolean executeMultiCellEdit(final String title, final Runnable edit) + { + return executeEdit(edit); + } + @Override public void fillContextMenu(final IMenuManager menu, final String region) { } + + @Override + public boolean hasActiveCellEditor() + { + return false; + } + + @Override + public GridSelection getSelection() + { + return GridSelection.NONE; + } + + @Override + public void setPendingSelection(final int columnIndex, final int rowIndex) + { + } } } diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridSelectionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridSelectionTest.java new file mode 100644 index 00000000..85baa242 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridSelectionTest.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.grid; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.eclipse.nebula.widgets.nattable.coordinate.PositionCoordinate; +import org.eclipse.nebula.widgets.nattable.data.IDataProvider; +import org.eclipse.nebula.widgets.nattable.layer.DataLayer; +import org.eclipse.nebula.widgets.nattable.selection.SelectionLayer; +import org.eclipse.nebula.widgets.nattable.selection.command.SelectAllCommand; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link GridSelection} against the Grid specification's rule that it must never call + * {@code SelectionLayer.getSelectedCellPositions()}, whose cost grows with the number of selected cells. + */ +class GridSelectionTest +{ + @Test + void testCompute_ofSelectAllOnA100000RowTable_hasTheRightBoundsAndRowIndexes() + { + final int rowCount = 100_000; + final int columnCount = 3; + final SelectionLayer selectionLayer = new SelectionLayer(new DataLayer(new FixedSizeDataProvider( + rowCount, columnCount))) + { + @Override + public PositionCoordinate[] getSelectedCellPositions() + { + throw new AssertionError( + "GridSelection must compute bounds and row indexes without calling " + + "getSelectedCellPositions()."); + } + }; + selectionLayer.doCommand(new SelectAllCommand()); + + final GridSelection selection = GridSelection.compute("USERS", columnCount, selectionLayer); + + assertThat(selection.rowIndexes()).as("Select All must select every row.").hasSize(rowCount); + assertThat(selection.firstRowIndex()).as("The first selected row must be 0.").isZero(); + assertThat(selection.lastRowIndex()).as("The last selected row must be the last row index.") + .isEqualTo(rowCount - 1); + assertThat(selection.firstColumnIndex()).as("The first selected column must be 0.").isZero(); + assertThat(selection.lastColumnIndex()).as("The last selected column must be the last column index.") + .isEqualTo(columnCount - 1); + assertThat(selection.wholeRowsSelected()).as("Select All must select every row in full.").isTrue(); + } + + private static final class FixedSizeDataProvider implements IDataProvider + { + private final int rowCount; + + private final int columnCount; + + FixedSizeDataProvider(final int rowCount, final int columnCount) + { + this.rowCount = rowCount; + this.columnCount = columnCount; + } + + @Override + public int getColumnCount() + { + return columnCount; + } + + @Override + public int getRowCount() + { + return rowCount; + } + + @Override + public Object getDataValue(final int columnIndex, final int rowIndex) + { + return null; + } + + @Override + public void setDataValue(final int columnIndex, final int rowIndex, final Object newValue) + { + } + } +} From 94506a827e156e4a0d3360091e352b95046739fe Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Thu, 24 Sep 2026 23:44:00 -0500 Subject: [PATCH 020/170] feat(grid): Duplicate and move rows * Add DuplicateRowsAction, inserting copies of the selected rows directly after them, in their order, and selecting the new block. * Add MoveRowsUpAction and MoveRowsDownAction, moving a contiguous block of selected rows by one position and keeping it selected; each is enabled only for a contiguous selection that is not already at the edge it would move past. * GridSelection grows rowCount and isContiguousRowSelection(), and DatasetGrid passes the table's row count when computing it. * Wire the three actions into the Tables page's handler activation and body and row header context menus, and declare their commands and key bindings in plugin.xml. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr --- .../plugin.properties | 3 + .../org.dbunit.eclipse.dataset.ui/plugin.xml | 15 +++ .../dataset/ui/actions/DatasetCommandIds.java | 15 +++ .../ui/actions/DuplicateRowsAction.java | 66 +++++++++++ .../ui/actions/MoveRowsDownAction.java | 64 +++++++++++ .../dataset/ui/actions/MoveRowsUpAction.java | 63 +++++++++++ .../eclipse/dataset/ui/editor/TablesPage.java | 18 +++ .../eclipse/dataset/ui/grid/DatasetGrid.java | 4 +- .../dataset/ui/grid/GridSelection.java | 34 +++++- .../dataset/ui/actions/GridActionsTest.java | 103 +++++++++++++++++- .../dataset/ui/grid/GridSelectionTest.java | 37 ++++++- 11 files changed, 409 insertions(+), 13 deletions(-) create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DuplicateRowsAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/MoveRowsDownAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/MoveRowsUpAction.java diff --git a/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties b/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties index 3098fe0d..f43f49e5 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties +++ b/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties @@ -7,3 +7,6 @@ command.category.name = dbUnit Dataset command.insertRowAbove.name = Insert Row Above command.insertRowBelow.name = Insert Row Below command.deleteRows.name = Delete Rows +command.duplicateRows.name = Duplicate Rows +command.moveRowsUp.name = Move Rows Up +command.moveRowsDown.name = Move Rows Down diff --git a/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml b/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml index 7de72146..ba93547a 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml +++ b/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml @@ -34,6 +34,12 @@ categoryId="org.dbunit.eclipse.dataset.ui.category"/> + + + + + + 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 index 3490a687..3e3d080a 100644 --- 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 @@ -47,6 +47,21 @@ public final class DatasetCommandIds */ 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"; + private DatasetCommandIds() { } 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..deb31d17 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DuplicateRowsAction.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.actions; + +import org.dbunit.eclipse.dataset.ui.DatasetImages; +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("Duplicate Rows"); + 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 columnIndex = Math.max(selection.anchorColumnIndex(), 0); + final int newBlockStart = selection.lastRowIndex() + 1; + final boolean applied = context.executeEdit( + () -> context.getDatasetDocument().duplicateRows(selection.tableKey(), rowIndexes)); + if (applied) + { + context.setPendingSelection(columnIndex, newBlockStart); + } + } + + @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/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..3a1b3c76 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/MoveRowsDownAction.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 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 GridAction +{ + /** + * 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); + setText("Move Rows Down"); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final int columnIndex = Math.max(selection.anchorColumnIndex(), 0); + final int newFirstRowIndex = selection.firstRowIndex() + 1; + final boolean applied = context.executeEdit(() -> context.getDatasetDocument().moveRows( + selection.tableKey(), selection.firstRowIndex(), selection.rowIndexes().size(), 1)); + if (applied) + { + context.setPendingSelection(columnIndex, newFirstRowIndex); + } + } + + @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..de1f6f7f --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/MoveRowsUpAction.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 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 GridAction +{ + /** + * 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); + setText("Move Rows Up"); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final int columnIndex = Math.max(selection.anchorColumnIndex(), 0); + final int newFirstRowIndex = selection.firstRowIndex() - 1; + final boolean applied = context.executeEdit(() -> context.getDatasetDocument().moveRows( + selection.tableKey(), selection.firstRowIndex(), selection.rowIndexes().size(), -1)); + if (applied) + { + context.setPendingSelection(columnIndex, newFirstRowIndex); + } + } + + @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/editor/TablesPage.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/TablesPage.java index 8ec4748f..ce586e14 100644 --- 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 @@ -37,9 +37,12 @@ import org.dbunit.eclipse.dataset.core.model.ProblemSeverity; import org.dbunit.eclipse.dataset.ui.DatasetUiPlugin; import org.dbunit.eclipse.dataset.ui.actions.DeleteRowsAction; +import org.dbunit.eclipse.dataset.ui.actions.DuplicateRowsAction; 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.grid.DatasetGrid; import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; import org.dbunit.eclipse.dataset.ui.grid.GridSelection; @@ -152,6 +155,12 @@ public void documentChanged(final DocumentEvent event) private final DeleteRowsAction deleteRowsAction; + private final DuplicateRowsAction duplicateRowsAction; + + private final MoveRowsUpAction moveRowsUpAction; + + private final MoveRowsDownAction moveRowsDownAction; + private final List gridActions = new ArrayList<>(); private final List handlerActivations = new ArrayList<>(); @@ -210,9 +219,15 @@ public void documentChanged(final DocumentEvent event) insertRowAboveAction = new InsertRowAboveAction(this); insertRowBelowAction = new InsertRowBelowAction(this); deleteRowsAction = new DeleteRowsAction(this); + duplicateRowsAction = new DuplicateRowsAction(this); + moveRowsUpAction = new MoveRowsUpAction(this); + moveRowsDownAction = new MoveRowsDownAction(this); gridActions.add(insertRowAboveAction); gridActions.add(insertRowBelowAction); gridActions.add(deleteRowsAction); + gridActions.add(duplicateRowsAction); + gridActions.add(moveRowsUpAction); + gridActions.add(moveRowsDownAction); final ToolBarManager toolBarManager = new ToolBarManager(SWT.FLAT); toolBarManager.add(insertRowBelowAction); @@ -405,7 +420,10 @@ public void fillContextMenu(final IMenuManager menu, final String region) { menu.add(insertRowAboveAction); menu.add(insertRowBelowAction); + menu.add(duplicateRowsAction); menu.add(deleteRowsAction); + menu.add(moveRowsUpAction); + menu.add(moveRowsDownAction); } } 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 index ad5c93fe..2a2c4abd 100644 --- 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 @@ -180,8 +180,8 @@ SelectionLayer getSelectionLayer() */ public GridSelection getSelection() { - return GridSelection.compute(bodyDataProvider.getTableKey(), bodyDataProvider.getColumnCount(), - selectionLayer); + return GridSelection.compute(bodyDataProvider.getTableKey(), bodyDataProvider.getRowCount(), + bodyDataProvider.getColumnCount(), selectionLayer); } /** 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 index 76eca384..2730c5e5 100644 --- 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 @@ -34,6 +34,7 @@ * 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}. @@ -47,25 +48,36 @@ * selected. * @since 1.0.0 */ -public record GridSelection(String tableKey, int columnCount, int anchorColumnIndex, int anchorRowIndex, - List rowIndexes, List columnIndexes, int firstRowIndex, int lastRowIndex, - int firstColumnIndex, int lastColumnIndex, boolean wholeRowsSelected) +public record GridSelection(String tableKey, int rowCount, int columnCount, int anchorColumnIndex, + int anchorRowIndex, List rowIndexes, List columnIndexes, int firstRowIndex, + int lastRowIndex, int firstColumnIndex, int lastColumnIndex, 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, SelectionLayer.NO_SELECTION, + public static final GridSelection NONE = new GridSelection(null, 0, 0, SelectionLayer.NO_SELECTION, SelectionLayer.NO_SELECTION, List.of(), List.of(), -1, -1, -1, -1, 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 columnCount, + public static GridSelection compute(final String tableKey, final int rowCount, final int columnCount, final SelectionLayer selectionLayer) { final PositionCoordinate anchor = selectionLayer.getSelectionAnchor(); @@ -93,11 +105,21 @@ public static GridSelection compute(final String tableKey, final int columnCount break; } } - return new GridSelection(tableKey, columnCount, anchor.columnPosition, anchor.rowPosition, + return new GridSelection(tableKey, rowCount, columnCount, anchor.columnPosition, anchor.rowPosition, rowIndexes, columnIndexes, boundStart(rowIndexes), boundEnd(rowIndexes), boundStart(columnIndexes), boundEnd(columnIndexes), wholeRowsSelected); } + /** + * 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(); + } + private static int boundStart(final List indexes) { return indexes.isEmpty() ? -1 : indexes.get(0); diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java index f46f6473..7bdad999 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java @@ -31,6 +31,7 @@ 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.DatasetTable; import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; import org.dbunit.eclipse.dataset.ui.grid.GridSelection; import org.eclipse.core.commands.ExecutionException; @@ -173,6 +174,99 @@ void testRun_whileACellEditorIsActive_changesNothing() .as("Insert Row Below must do nothing while a cell editor is active.").hasSize(1); } + @Test + void testDuplicateRows_withTwoRowsSelected_insertsCopiesDirectlyAfterThemAndSelectsTheNewBlock() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 1); + 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.pendingSelectionRow).as("Duplicate Rows must select the new block's first row.") + .isEqualTo(2); + } + + @Test + void testMoveRowsUp_withAContiguousBlockSelected_movesItUpAndKeepsItSelected() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(1, 2); + 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.pendingSelectionRow).as("Move Rows Up must keep the block selected.") + .isEqualTo(0); + } + + @Test + void testMoveRowsDown_withAContiguousBlockSelected_movesItDownAndKeepsItSelected() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final TestContext context = new TestContext(datasetDocument, "USERS"); + context.rowIndexes = List.of(0, 1); + 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.pendingSelectionRow).as("Move Rows Down must keep the block selected.") + .isEqualTo(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(); + } + private static FlatXmlDatasetDocument create(final String content) { return create(new Document(content)); @@ -277,12 +371,13 @@ public boolean hasActiveCellEditor() @Override public GridSelection getSelection() { - final int columnCount = - datasetDocument.getModel().findTable(tableKey).orElseThrow().getColumns().size(); + final DatasetTable table = datasetDocument.getModel().findTable(tableKey).orElseThrow(); + final int rowCount = table.getRows().size(); + final int columnCount = table.getColumns().size(); final int firstRow = rowIndexes.isEmpty() ? -1 : rowIndexes.get(0); final int lastRow = rowIndexes.isEmpty() ? -1 : rowIndexes.get(rowIndexes.size() - 1); - return new GridSelection(tableKey, columnCount, anchorColumnIndex, anchorRowIndex, rowIndexes, - List.of(), firstRow, lastRow, -1, -1, !rowIndexes.isEmpty()); + return new GridSelection(tableKey, rowCount, columnCount, anchorColumnIndex, anchorRowIndex, + rowIndexes, List.of(), firstRow, lastRow, -1, -1, !rowIndexes.isEmpty()); } @Override diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridSelectionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridSelectionTest.java index 85baa242..02c2715b 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridSelectionTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridSelectionTest.java @@ -21,6 +21,10 @@ package org.dbunit.eclipse.dataset.ui.grid; 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.nebula.widgets.nattable.coordinate.PositionCoordinate; import org.eclipse.nebula.widgets.nattable.data.IDataProvider; @@ -53,7 +57,8 @@ public PositionCoordinate[] getSelectedCellPositions() }; selectionLayer.doCommand(new SelectAllCommand()); - final GridSelection selection = GridSelection.compute("USERS", columnCount, selectionLayer); + final GridSelection selection = + GridSelection.compute("USERS", rowCount, columnCount, selectionLayer); assertThat(selection.rowIndexes()).as("Select All must select every row.").hasSize(rowCount); assertThat(selection.firstRowIndex()).as("The first selected row must be 0.").isZero(); @@ -65,6 +70,36 @@ public PositionCoordinate[] getSelectedCellPositions() assertThat(selection.wholeRowsSelected()).as("Select All must select every row in full.").isTrue(); } + @Test + void testConstructor_whenTheGivenListsChangeAfterwards_keepsTheIndexesItWasGiven() + { + final List rowIndexes = new ArrayList<>(List.of(1, 2)); + final List columnIndexes = new ArrayList<>(List.of(0)); + final GridSelection selection = + new GridSelection("USERS", 3, 1, 0, 1, rowIndexes, columnIndexes, 1, 2, 0, 0, true); + + rowIndexes.add(3); + columnIndexes.add(1); + + assertThat(selection).as("A later change to the caller's lists must not change the selection.") + .isEqualTo(new GridSelection("USERS", 3, 1, 0, 1, List.of(1, 2), List.of(0), 1, 2, 0, 0, + true)); + } + + @Test + void testIndexes_whenAReturnedListIsModified_throws() + { + final GridSelection selection = new GridSelection("USERS", 3, 1, 0, 1, + new ArrayList<>(List.of(1, 2)), new ArrayList<>(List.of(0)), 1, 2, 0, 0, true); + + assertThatThrownBy(() -> selection.rowIndexes().add(3)) + .as("The returned row indexes must not let a caller change the selection.") + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> selection.columnIndexes().add(1)) + .as("The returned column indexes must not let a caller change the selection.") + .isInstanceOf(UnsupportedOperationException.class); + } + private static final class FixedSizeDataProvider implements IDataProvider { private final int rowCount; From 00e97a570c5ff36215fb51e07cbbb6db53d19604 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Thu, 24 Sep 2026 23:52:27 -0500 Subject: [PATCH 021/170] feat(grid): Add, rename, and delete columns * Add DatasetNameValidator, an IInputValidator rejecting a name that is not a valid XML name or is already used, case-insensitively. * Add AddColumnAction and RenameColumnAction, prompting for a name with an InputDialog; each exposes its dialog creation as a package-private method a test can override with a stubbed result. * Add DeleteColumnAction, confirming with a dialog that names the column and the number of values it will remove, and, for a column declared in the DTD, notes that the DTD must be updated too. * DatasetGridContext grows getShell(), so actions can parent dialogs without depending on SWT composites directly. * Wire the three actions into the Tables page's handler activation, the column header context menu, and (Add and Delete Column) the tab folder's toolbar; declare their commands in plugin.xml. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr --- .../plugin.properties | 3 + .../org.dbunit.eclipse.dataset.ui/plugin.xml | 6 + .../dataset/ui/actions/AddColumnAction.java | 94 +++++++++ .../dataset/ui/actions/DatasetCommandIds.java | 15 ++ .../ui/actions/DeleteColumnAction.java | 106 ++++++++++ .../ui/actions/RenameColumnAction.java | 120 ++++++++++++ .../ui/dialogs/DatasetNameValidator.java | 72 +++++++ .../eclipse/dataset/ui/editor/TablesPage.java | 30 +++ .../dataset/ui/grid/DatasetGridContext.java | 8 + .../dataset/ui/actions/GridActionsTest.java | 182 ++++++++++++++++++ .../ui/dialogs/DatasetNameValidatorTest.java | 95 +++++++++ .../dataset/ui/grid/DatasetGridTest.java | 6 + .../dataset/ui/grid/GridEditingTest.java | 6 + 13 files changed, 743 insertions(+) create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/AddColumnAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DeleteColumnAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/RenameColumnAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/dialogs/DatasetNameValidator.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/dialogs/DatasetNameValidatorTest.java diff --git a/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties b/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties index f43f49e5..aa31cf85 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties +++ b/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties @@ -10,3 +10,6 @@ command.deleteRows.name = Delete Rows command.duplicateRows.name = Duplicate Rows command.moveRowsUp.name = Move Rows Up command.moveRowsDown.name = Move Rows Down +command.addColumn.name = Add Column... +command.renameColumn.name = Rename Column... +command.deleteColumn.name = Delete Column diff --git a/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml b/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml index ba93547a..61593270 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml +++ b/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml @@ -40,6 +40,12 @@ categoryId="org.dbunit.eclipse.dataset.ui.category"/> + + + existingNames = new ArrayList<>(); + for (final DatasetColumn column : table.getColumns()) + { + existingNames.add(column.name()); + } + final String columnName = + openNameDialog(context.getShell(), new DatasetNameValidator(existingNames)); + 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. + * @return The entered name, or null when the dialog was cancelled. + */ + String openNameDialog(final Shell shell, final IInputValidator validator) + { + final InputDialog dialog = new InputDialog(shell, "Add Column", "Column name:", "", validator); + return dialog.open() == Window.OK ? dialog.getValue() : null; + } + + @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/DatasetCommandIds.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DatasetCommandIds.java index 3e3d080a..6d0b021e 100644 --- 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 @@ -62,6 +62,21 @@ public final class DatasetCommandIds */ 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"; + private DatasetCommandIds() { } 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..489ee24d --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DeleteColumnAction.java @@ -0,0 +1,106 @@ +/* + * + * 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.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.jface.dialogs.MessageDialog; +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("Delete Column"); + setImageDescriptor(DatasetImages.getImageDescriptor(DatasetImages.IMG_DELETE_COLUMN)); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final DatasetTable table = + context.getDatasetDocument().getModel().findTable(selection.tableKey()).orElseThrow(); + 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 StringBuilder message = new StringBuilder(); + message.append("Delete column '").append(column.name()).append("'? This removes ") + .append(valueCount).append(valueCount == 1 ? " value." : " values."); + if (column.declared()) + { + message.append(" dbUnit reads columns from the DTD, so the DTD must be updated too."); + } + return MessageDialog.openConfirm(shell, "Delete Column", message.toString()); + } + + @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/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..65ec248d --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/RenameColumnAction.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.actions; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import org.dbunit.eclipse.dataset.core.model.DatasetColumn; +import org.dbunit.eclipse.dataset.core.model.DatasetTable; +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("Rename Column..."); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final DatasetTable table = + context.getDatasetDocument().getModel().findTable(selection.tableKey()).orElseThrow(); + final DatasetColumn currentColumn = table.getColumns().get(selection.anchorColumnIndex()); + final String currentName = currentColumn.name(); + final String currentKey = currentName.toUpperCase(Locale.ENGLISH); + final List existingNames = new ArrayList<>(); + for (final DatasetColumn column : table.getColumns()) + { + if (!column.name().toUpperCase(Locale.ENGLISH).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, "Rename Column", 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() + ? "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." + : "Column 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/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..e347a29e --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/dialogs/DatasetNameValidator.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.dialogs; + +import java.util.Collection; +import java.util.List; +import java.util.Locale; + +import org.dbunit.eclipse.dataset.core.flatxml.XmlNames; +import org.eclipse.jface.dialogs.IInputValidator; + +/** + * 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 equal any of them after + * upper-casing both with Locale.ENGLISH, which is how the dataset model keys + * columns. 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 "Enter a name."; + } + if (!XmlNames.isValidName(newText)) + { + return "'" + newText + "' is not a valid XML name."; + } + final String newKey = newText.toUpperCase(Locale.ENGLISH); + for (final String existingName : existingNames) + { + if (existingName.toUpperCase(Locale.ENGLISH).equals(newKey)) + { + return "'" + newText + "' is already used."; + } + } + return null; + } +} 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 index ce586e14..b00efbcc 100644 --- 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 @@ -36,6 +36,8 @@ import org.dbunit.eclipse.dataset.core.model.DatasetTable; import org.dbunit.eclipse.dataset.core.model.ProblemSeverity; import org.dbunit.eclipse.dataset.ui.DatasetUiPlugin; +import org.dbunit.eclipse.dataset.ui.actions.AddColumnAction; +import org.dbunit.eclipse.dataset.ui.actions.DeleteColumnAction; import org.dbunit.eclipse.dataset.ui.actions.DeleteRowsAction; import org.dbunit.eclipse.dataset.ui.actions.DuplicateRowsAction; import org.dbunit.eclipse.dataset.ui.actions.GridAction; @@ -43,6 +45,7 @@ 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.RenameColumnAction; import org.dbunit.eclipse.dataset.ui.grid.DatasetGrid; import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; import org.dbunit.eclipse.dataset.ui.grid.GridSelection; @@ -78,6 +81,7 @@ import org.eclipse.swt.widgets.Control; import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Label; +import org.eclipse.swt.widgets.Shell; import org.eclipse.swt.widgets.ToolBar; import org.eclipse.text.undo.DocumentUndoManagerRegistry; import org.eclipse.text.undo.IDocumentUndoManager; @@ -161,6 +165,12 @@ public void documentChanged(final DocumentEvent event) private final MoveRowsDownAction moveRowsDownAction; + private final AddColumnAction addColumnAction; + + private final RenameColumnAction renameColumnAction; + + private final DeleteColumnAction deleteColumnAction; + private final List gridActions = new ArrayList<>(); private final List handlerActivations = new ArrayList<>(); @@ -222,16 +232,24 @@ public void documentChanged(final DocumentEvent event) duplicateRowsAction = new DuplicateRowsAction(this); moveRowsUpAction = new MoveRowsUpAction(this); moveRowsDownAction = new MoveRowsDownAction(this); + addColumnAction = new AddColumnAction(this); + renameColumnAction = new RenameColumnAction(this); + deleteColumnAction = new DeleteColumnAction(this); 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); final ToolBarManager toolBarManager = new ToolBarManager(SWT.FLAT); toolBarManager.add(insertRowBelowAction); toolBarManager.add(deleteRowsAction); + toolBarManager.add(addColumnAction); + toolBarManager.add(deleteColumnAction); final ToolBar toolBar = toolBarManager.createControl(tabFolder); tabFolder.setTopRight(toolBar); @@ -425,6 +443,12 @@ public void fillContextMenu(final IMenuManager menu, final String region) menu.add(moveRowsUpAction); menu.add(moveRowsDownAction); } + else if (GridRegion.COLUMN_HEADER.equals(region)) + { + menu.add(addColumnAction); + menu.add(renameColumnAction); + menu.add(deleteColumnAction); + } } @Override @@ -452,6 +476,12 @@ public void setPendingSelection(final int columnIndex, final int rowIndex) } } + @Override + public Shell getShell() + { + return control.getShell(); + } + private static double relativeLuminance(final Color color) { return 0.2126 * linearize(color.getRed()) + 0.7152 * linearize(color.getGreen()) 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 index b0c62235..747eb49b 100644 --- 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 @@ -22,6 +22,7 @@ import org.dbunit.eclipse.dataset.core.edit.DatasetDocument; import org.eclipse.jface.action.IMenuManager; +import org.eclipse.swt.widgets.Shell; /** * What a {@link DatasetGrid} needs from the page that hosts it. @@ -107,4 +108,11 @@ public interface DatasetGridContext * @param rowIndex The row of the cell to select. */ void setPendingSelection(int columnIndex, int rowIndex); + + /** + * Returns the shell to parent dialogs on. + * + * @return The page's shell. + */ + Shell getShell(); } diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java index 7bdad999..d6253597 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java @@ -31,13 +31,16 @@ 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.DatasetColumn; 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.core.commands.ExecutionException; import org.eclipse.jface.action.IMenuManager; +import org.eclipse.jface.dialogs.IInputValidator; import org.eclipse.jface.text.Document; import org.eclipse.jface.text.IDocument; +import org.eclipse.swt.widgets.Shell; import org.eclipse.text.undo.DocumentUndoManagerRegistry; import org.eclipse.text.undo.IDocumentUndoManager; import org.junit.jupiter.api.Test; @@ -267,6 +270,179 @@ void testUpdate_forMoveRowsUpAndDown_disabledWhenNotContiguousOrAtTheEdge() .isFalse(); } + @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) + { + 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"); + } + + @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(); + } + + @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"); + } + private static FlatXmlDatasetDocument create(final String content) { return create(new Document(content)); @@ -386,5 +562,11 @@ public void setPendingSelection(final int columnIndex, final int rowIndex) pendingSelectionColumn = columnIndex; pendingSelectionRow = rowIndex; } + + @Override + public Shell getShell() + { + return null; + } } } 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/grid/DatasetGridTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java index 08fb1d31..b951a31a 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java @@ -306,5 +306,11 @@ public GridSelection getSelection() public void setPendingSelection(final int columnIndex, final int rowIndex) { } + + @Override + public Shell getShell() + { + return null; + } } } diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java index 85f1d312..0ec07d72 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java @@ -389,5 +389,11 @@ public GridSelection getSelection() public void setPendingSelection(final int columnIndex, final int rowIndex) { } + + @Override + public Shell getShell() + { + return null; + } } } From 3dc1022ac58c58e98ce4195652f210bc75e351fb Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Fri, 25 Sep 2026 00:07:18 -0500 Subject: [PATCH 022/170] feat(editor): Add, rename, and delete tables * Add a new table with an optional comma-separated list of column names, entered through a dialog; the new table's tab is selected. * Rename the active table through a dialog; the rename keeps its tab and selection. * Delete the active table after a confirmation naming it and its row count; the tab is removed by the existing tab reconciliation. * Add a tab context menu, shown via a MenuDetectListener on the CTabFolder, offering all three commands. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr --- .../plugin.properties | 3 + .../org.dbunit.eclipse.dataset.ui/plugin.xml | 6 + .../dataset/ui/actions/AddTableAction.java | 98 ++++++ .../dataset/ui/actions/DatasetCommandIds.java | 15 + .../dataset/ui/actions/DeleteTableAction.java | 95 ++++++ .../dataset/ui/actions/RenameTableAction.java | 121 +++++++ .../dataset/ui/dialogs/AddTableDialog.java | 149 +++++++++ .../ui/dialogs/ColumnNamesValidator.java | 65 ++++ .../ui/dialogs/TableNameValidator.java | 83 +++++ .../eclipse/dataset/ui/editor/TablesPage.java | 110 +++++- .../dataset/ui/grid/DatasetGridContext.java | 29 ++ .../dataset/ui/actions/GridActionsTest.java | 312 ++++++++++++++++++ .../ui/dialogs/AddTableDialogTest.java | 103 ++++++ .../ui/dialogs/ColumnNamesValidatorTest.java | 90 +++++ .../ui/dialogs/TableNameValidatorTest.java | 132 ++++++++ .../dataset/ui/editor/TablesPageTest.java | 80 +++++ .../dataset/ui/grid/DatasetGridTest.java | 20 ++ .../dataset/ui/grid/GridEditingTest.java | 20 ++ 18 files changed, 1523 insertions(+), 8 deletions(-) create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/AddTableAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DeleteTableAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/RenameTableAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/dialogs/AddTableDialog.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/dialogs/ColumnNamesValidator.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/dialogs/TableNameValidator.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/dialogs/AddTableDialogTest.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/dialogs/ColumnNamesValidatorTest.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/dialogs/TableNameValidatorTest.java diff --git a/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties b/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties index aa31cf85..3d28ba1e 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties +++ b/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties @@ -13,3 +13,6 @@ command.moveRowsDown.name = Move Rows Down command.addColumn.name = Add Column... command.renameColumn.name = Rename Column... command.deleteColumn.name = Delete Column +command.addTable.name = Add Table... +command.renameTable.name = Rename Table... +command.deleteTable.name = Delete Table diff --git a/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml b/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml index 61593270..79eb81a8 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml +++ b/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml @@ -46,6 +46,12 @@ categoryId="org.dbunit.eclipse.dataset.ui.category"/> + + + 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.expectNewTableSelected(context.getDatasetDocument().tableKeyOf(tableName)); + final boolean added = + context.executeEdit(() -> context.getDatasetDocument().addTable(tableName, columnNames)); + if (added == false) + { + context.cancelExpectedNewTableSelected(); + } + } + + /** + * 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/DatasetCommandIds.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DatasetCommandIds.java index 6d0b021e..9aa763a1 100644 --- 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 @@ -77,6 +77,21 @@ public final class DatasetCommandIds */ 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"; + private DatasetCommandIds() { } 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..1e684979 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DeleteTableAction.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 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.dialogs.MessageDialog; +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("Delete Table"); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final DatasetTable table = + context.getDatasetDocument().getModel().findTable(selection.tableKey()).orElseThrow(); + 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, "Delete Table", 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 = "Delete table '" + table.getName() + "'? This removes " + rowCount + + (rowCount == 1 ? " row." : " rows."); + return table.isDeclaredOnly() + ? deleteMessage + " dbUnit reads tables from the DTD, so the DTD must be updated too." + : 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/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..44c5a2b6 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/RenameTableAction.java @@ -0,0 +1,121 @@ +/* + * + * 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.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("Rename Table..."); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final DatasetTable currentTable = + context.getDatasetDocument().getModel().findTable(selection.tableKey()).orElseThrow(); + 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.expectRename(selection.tableKey(), context.getDatasetDocument().tableKeyOf(newName)); + final boolean renamed = context.executeEdit( + () -> context.getDatasetDocument().renameTable(selection.tableKey(), newName)); + if (renamed == false) + { + context.cancelExpectedRename(); + } + } + + /** + * 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, "Rename Table", 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. + * + * @param table The table being renamed. + * @return The dialog message. + */ + static String nameDialogMessage(final DatasetTable table) + { + return table.isDeclaredOnly() + ? "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." + : "Table 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/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..135908ef --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/dialogs/AddTableDialog.java @@ -0,0 +1,149 @@ +/* + * + * 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.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("Add Table"); + } + + @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("Table 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("Column names (comma-separated, optional):"); + 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(), "Add Table", validationMessage); + return; + } + final List enteredColumnNames = parseColumnNames(columnsText.getText()); + final String columnValidationMessage = new ColumnNamesValidator().isValid(enteredColumnNames); + if (columnValidationMessage != null) + { + MessageDialog.openError(getShell(), "Add Table", 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..e1e824bf --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/dialogs/ColumnNamesValidator.java @@ -0,0 +1,65 @@ +/* + * + * 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 java.util.Locale; + +import org.dbunit.eclipse.dataset.core.flatxml.XmlNames; + +/** + * Validates a table's optional column names: each must be a valid XML name, and no two may be equal after + * upper-casing them with Locale.ENGLISH, which is how the dataset model keys columns. + * + * @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 "'" + columnName + "' is not a valid XML name."; + } + final String columnKey = columnName.toUpperCase(Locale.ENGLISH); + for (final String seenColumnName : seenColumnNames) + { + if (seenColumnName.toUpperCase(Locale.ENGLISH).equals(columnKey)) + { + return "'" + columnName + "' is already used."; + } + } + seenColumnNames.add(columnName); + } + 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..a22957c4 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/dialogs/TableNameValidator.java @@ -0,0 +1,83 @@ +/* + * + * 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.eclipse.jface.dialogs.IInputValidator; + +/** + * 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 "Enter a name."; + } + if (!XmlNames.isValidName(newText)) + { + return "'" + newText + "' is not a valid XML name."; + } + final String newKey = tableKeyOf.apply(newText); + if (newKey.equals(tableKeyOf.apply(RESERVED_ROOT_NAME))) + { + return "'" + newText + "' is reserved for the root element."; + } + for (final String existingName : existingNames) + { + if (tableKeyOf.apply(existingName).equals(newKey)) + { + return "'" + newText + "' is already used."; + } + } + return null; + } +} 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 index b00efbcc..2282a7b8 100644 --- 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 @@ -37,8 +37,10 @@ import org.dbunit.eclipse.dataset.core.model.ProblemSeverity; import org.dbunit.eclipse.dataset.ui.DatasetUiPlugin; import org.dbunit.eclipse.dataset.ui.actions.AddColumnAction; +import org.dbunit.eclipse.dataset.ui.actions.AddTableAction; 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.GridAction; import org.dbunit.eclipse.dataset.ui.actions.InsertRowAboveAction; @@ -46,6 +48,7 @@ import org.dbunit.eclipse.dataset.ui.actions.MoveRowsDownAction; import org.dbunit.eclipse.dataset.ui.actions.MoveRowsUpAction; import org.dbunit.eclipse.dataset.ui.actions.RenameColumnAction; +import org.dbunit.eclipse.dataset.ui.actions.RenameTableAction; import org.dbunit.eclipse.dataset.ui.grid.DatasetGrid; import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; import org.dbunit.eclipse.dataset.ui.grid.GridSelection; @@ -55,6 +58,7 @@ import org.eclipse.core.commands.operations.OperationHistoryFactory; 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.jface.dialogs.MessageDialog; @@ -74,6 +78,7 @@ import org.eclipse.swt.graphics.Color; import org.eclipse.swt.graphics.Font; import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.graphics.Point; import org.eclipse.swt.layout.GridData; import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Button; @@ -124,6 +129,10 @@ final class TablesPage implements DatasetGridContext private final Button createEmptyDatasetButton; + private final Composite noTablesComposite; + + private final Button addTableButton; + private final Map tabsByKey = new LinkedHashMap<>(); private final Map gridsByKey = new LinkedHashMap<>(); @@ -171,6 +180,12 @@ public void documentChanged(final DocumentEvent event) private final DeleteColumnAction deleteColumnAction; + private final AddTableAction addTableAction; + + private final RenameTableAction renameTableAction; + + private final DeleteTableAction deleteTableAction; + private final List gridActions = new ArrayList<>(); private final List handlerActivations = new ArrayList<>(); @@ -191,6 +206,8 @@ public void documentChanged(final DocumentEvent event) private String expectedRenameNewKey; + private String expectedNewTableKey; + TablesPage(final Composite parent, final FlatXmlDatasetEditor editor, final FlatXmlDatasetDocument datasetDocument) { @@ -222,6 +239,17 @@ public void documentChanged(final DocumentEvent event) 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("The dataset has no tables."); + noTablesLabel.setLayoutData(new GridData(SWT.CENTER, SWT.BOTTOM, true, true)); + addTableButton = new Button(noTablesComposite, SWT.PUSH); + addTableButton.setText("Add Table..."); + 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); tabFolder.addSelectionListener( SelectionListener.widgetSelectedAdapter(event -> updateGridActionsEnablement())); @@ -235,6 +263,9 @@ public void documentChanged(final DocumentEvent event) addColumnAction = new AddColumnAction(this); renameColumnAction = new RenameColumnAction(this); deleteColumnAction = new DeleteColumnAction(this); + addTableAction = new AddTableAction(this); + renameTableAction = new RenameTableAction(this); + deleteTableAction = new DeleteTableAction(this); gridActions.add(insertRowAboveAction); gridActions.add(insertRowBelowAction); gridActions.add(deleteRowsAction); @@ -244,15 +275,39 @@ public void documentChanged(final DocumentEvent event) gridActions.add(addColumnAction); gridActions.add(renameColumnAction); gridActions.add(deleteColumnAction); + gridActions.add(addTableAction); + gridActions.add(renameTableAction); + gridActions.add(deleteTableAction); 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 IDocument document = sourceDocument(); listenedDocument = document; document.addDocumentListener(sourceDocumentListener); @@ -353,19 +408,32 @@ IAction getGlobalActionHandler(final String actionDefinitionId) return null; } - /** - * Tells the page that the table currently keyed {@code oldKey} is about to become {@code newKey}, so - * the next reconciliation keeps its tab instead of disposing and recreating it. - * - * @param oldKey The table's key before the rename. - * @param newKey The table's key after the rename. - */ - void expectRename(final String oldKey, final String newKey) + @Override + public void expectRename(final String oldKey, final String newKey) { expectedRenameOldKey = oldKey; expectedRenameNewKey = newKey; } + @Override + public void expectNewTableSelected(final String tableKey) + { + expectedNewTableKey = tableKey; + } + + @Override + public void cancelExpectedRename() + { + expectedRenameOldKey = null; + expectedRenameNewKey = null; + } + + @Override + public void cancelExpectedNewTableSelected() + { + expectedNewTableKey = null; + } + @Override public boolean isEditable() { @@ -514,6 +582,16 @@ Button getCreateEmptyDatasetButton() return createEmptyDatasetButton; } + boolean isShowingNoTablesState() + { + return noTablesComposite.equals(contentStackLayout.topControl); + } + + Button getAddTableButton() + { + return addTableButton; + } + private IDocument sourceDocument() { final ITextEditor sourceEditor = editor.getSourceEditor(); @@ -626,6 +704,12 @@ private void reconcile() createEmptyDatasetButton.setEnabled(inputModifiable); contentStackLayout.topControl = blankComposite; } + else if (model.getTables().isEmpty()) + { + reconcileTabs(DatasetModel.EMPTY); + addTableButton.setEnabled(editable); + contentStackLayout.topControl = noTablesComposite; + } else { reconcileTabs(model); @@ -658,6 +742,10 @@ private void reconcileTabs(final DatasetModel model) item = new CTabItem(tabFolder, SWT.NONE, index); item.setControl(grid.getControl()); tabsByKey.put(key, item); + if (key.equals(expectedNewTableKey)) + { + tabFolder.setSelection(item); + } } else if (tabFolder.indexOf(item) != index) { @@ -670,6 +758,7 @@ else if (tabFolder.indexOf(item) != index) } expectedRenameOldKey = null; expectedRenameNewKey = null; + expectedNewTableKey = null; disposeStaleTabs(seenKeys); selectFirstTabIfNoneSelected(); @@ -784,6 +873,11 @@ private void createEmptyDataset() } } + private void runAddTableAction() + { + addTableAction.run(); + } + 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/DatasetGridContext.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridContext.java index 747eb49b..5665ef43 100644 --- 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 @@ -115,4 +115,33 @@ public interface DatasetGridContext * @return The page's shell. */ Shell getShell(); + + /** + * Tells the page that the table currently keyed {@code oldKey} is about to become {@code newKey}, so + * the next reconciliation keeps its tab instead of disposing and recreating it. + * + * @param oldKey The table's key before the rename. + * @param newKey The table's key after the rename. + */ + void expectRename(String oldKey, String newKey); + + /** + * Cancels a pending {@link #expectRename}, for a rename whose edit did not apply, so a later, + * unrelated reconciliation cannot match its keys by coincidence. + */ + void cancelExpectedRename(); + + /** + * Cancels a pending {@link #expectNewTableSelected}, for a table add whose edit did not apply, so a + * later, unrelated reconciliation cannot select the wrong tab by coincidence. + */ + void cancelExpectedNewTableSelected(); + + /** + * Tells the page that a table with this key is about to be added, so the next reconciliation selects + * its new tab. + * + * @param tableKey The key of the table about to be added. + */ + void expectNewTableSelected(String tableKey); } diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java index d6253597..9ccb42b7 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java @@ -33,6 +33,7 @@ import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlOptions; 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.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; import org.dbunit.eclipse.dataset.ui.grid.GridSelection; import org.eclipse.core.commands.ExecutionException; @@ -443,6 +444,276 @@ boolean confirmDelete(final Shell shell, final DatasetColumn column, final int v .contains("NAME"); } + @Test + void testAddTable_whenTheDialogReturnsANameAndColumns_addsTheTableAndSelectsItsTab() + { + 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"); + assertThat(context.expectedNewTableKey) + .as("Add Table must select the new table's tab by its case-folded key.") + .isEqualTo("ACCOUNTS"); + } + + @Test + void testAddTable_whenTheEditIsRejected_cancelsTheNewTableExpectation() + { + 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(); + assertThat(context.expectedNewTableKey) + .as("A rejected edit must cancel the new-table expectation so a later reconciliation " + + "cannot match it by coincidence.") + .isNull(); + } + + @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(); + } + + @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 testRenameTable_whenTheDialogReturnsANewName_renamesTheTableAndKeepsItsTab() + { + 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(); + assertThat(context.expectedRenameOldKey).as("Rename Table must record the old tab key.") + .isEqualTo("USERS"); + assertThat(context.expectedRenameNewKey) + .as("Rename Table must record the new tab's case-folded key.").isEqualTo("CUSTOMERS"); + } + + @Test + void testRenameTable_whenTheEditIsRejected_cancelsTheRenameExpectation() + { + 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(); + assertThat(context.expectedRenameOldKey) + .as("A rejected edit must cancel the rename expectation so a later reconciliation cannot " + + "match it by coincidence.") + .isNull(); + assertThat(context.expectedRenameNewKey).isNull(); + } + + @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(); + } + + @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(); + } + private static FlatXmlDatasetDocument create(final String content) { return create(new Document(content)); @@ -456,6 +727,15 @@ private static FlatXmlDatasetDocument create(final IDocument document) return datasetDocument; } + private 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; + } + private static final class TestContext implements DatasetGridContext { private final DatasetDocument datasetDocument; @@ -478,6 +758,12 @@ private static final class TestContext implements DatasetGridContext private int pendingSelectionRow = -1; + private String expectedRenameOldKey; + + private String expectedRenameNewKey; + + private String expectedNewTableKey; + TestContext(final DatasetDocument datasetDocument, final String tableKey) { this.datasetDocument = datasetDocument; @@ -568,5 +854,31 @@ public Shell getShell() { return null; } + + @Override + public void expectRename(final String oldKey, final String newKey) + { + expectedRenameOldKey = oldKey; + expectedRenameNewKey = newKey; + } + + @Override + public void cancelExpectedRename() + { + expectedRenameOldKey = null; + expectedRenameNewKey = null; + } + + @Override + public void cancelExpectedNewTableSelected() + { + expectedNewTableKey = null; + } + + @Override + public void expectNewTableSelected(final String tableKey) + { + expectedNewTableKey = tableKey; + } } } 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/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/TablesPageTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java index fba943a0..4c141771 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java @@ -22,12 +22,15 @@ 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.edit.CellChange; import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; import org.dbunit.eclipse.dataset.ui.actions.DatasetCommandIds; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.core.commands.NotEnabledException; import org.eclipse.core.resources.IFile; import org.eclipse.jface.action.IAction; import org.eclipse.jface.text.IDocument; @@ -95,6 +98,33 @@ void testTablesPage_whenTablesAreAddedAndRemoved_updatesTheTabs() throws Excepti } } + @Test + void testTablesPage_whenTheLastTableIsDeleted_disposesTheStaleGridAndDisablesItsCommands() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final TablesPage tablesPage = editor.getTablesPage(); + final IHandlerService handlerService = + editor.getEditorSite().getService(IHandlerService.class); + + editor.getDatasetDocument().deleteTable("USERS"); + + assertThat(tablesPage.getSelection()) + .as("Deleting the last table must clear the selection, not keep the disposed " + + "grid's.") + .isEqualTo(GridSelection.NONE); + assertThat(tablesPage.getAddTableButton().getEnabled()) + .as("Add Table must stay available with no tables.").isTrue(); + assertThatThrownBy( + () -> handlerService.executeCommand(DatasetCommandIds.EDIT_CELL_IN_DIALOG, null)) + .as("Edit Cell in Dialog must not run on the disposed grid.") + .isInstanceOf(NotEnabledException.class); + } + } + @Test void testTablesPage_whenDtdContentModelRepeatsATableName_opensWithOneTabPerTable() throws Exception { @@ -223,6 +253,56 @@ void testTablesPage_whenBlankFileIsReadOnly_disablesCreateEmptyDatasetAndLeavesT } } + @Test + void testTablesPage_whenDatasetHasNoTables_showsTheNoTablesStateWithTheButtonEnabled() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final TablesPage tablesPage = editor.getTablesPage(); + + assertThat(tablesPage.isShowingNoTablesState()) + .as("A dataset without tables must show the no-tables state.").isTrue(); + assertThat(tablesPage.getAddTableButton().getEnabled()) + .as("The Add Table button must be enabled for an editable dataset.").isTrue(); + } + } + + @Test + void testTablesPage_whenATableIsAddedToAnEmptyDataset_showsTheTabFolderAgain() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final TablesPage tablesPage = editor.getTablesPage(); + + editor.getDatasetDocument().addTable("USERS", List.of()); + + assertThat(tablesPage.isShowingNoTablesState()) + .as("Adding a table must leave the no-tables state.").isFalse(); + assertThat(tablesPage.getTabFolder().getItemCount()).as("Adding a table must show its tab.") + .isEqualTo(1); + } + } + + @Test + void testTablesPage_whenNoTablesStateIsReadOnly_disablesTheAddTableButton() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final FlatXmlDatasetEditor editor = + (FlatXmlDatasetEditor) workspace.openReadOnlyExternalFile(""); + final TablesPage tablesPage = editor.getTablesPage(); + + assertThat(tablesPage.isShowingNoTablesState()) + .as("A read-only dataset without tables must still show the no-tables state.").isTrue(); + assertThat(tablesPage.getAddTableButton().getEnabled()) + .as("A read-only dataset must disable the Add Table button.").isFalse(); + } + } + @Test void testRefreshScheduling_whenTablesPageIsNotActive_refreshesOnlyOnActivation() throws Exception { diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java index b951a31a..48b30fae 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java @@ -312,5 +312,25 @@ public Shell getShell() { return null; } + + @Override + public void expectRename(final String oldKey, final String newKey) + { + } + + @Override + public void cancelExpectedRename() + { + } + + @Override + public void cancelExpectedNewTableSelected() + { + } + + @Override + public void expectNewTableSelected(final String tableName) + { + } } } diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java index 0ec07d72..434270a0 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java @@ -395,5 +395,25 @@ public Shell getShell() { return null; } + + @Override + public void expectRename(final String oldKey, final String newKey) + { + } + + @Override + public void cancelExpectedRename() + { + } + + @Override + public void cancelExpectedNewTableSelected() + { + } + + @Override + public void expectNewTableSelected(final String tableName) + { + } } } From a40e96552e7e0b00ed51ce0634c014d9c2a646ac Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Fri, 25 Sep 2026 06:59:13 -0500 Subject: [PATCH 023/170] feat(grid): Add clipboard and cell value commands * Add TabSeparatedValues (core): format and parse the tab-separated text spreadsheets put on the clipboard, with quoting for tabs, quotes, and line breaks, and CR LF, LF, and CR line ends. * Add Copy, Cut, and Paste, installed as the global Copy, Cut, and Paste actions: Copy writes the bounding rectangle of the selection, with unselected cells in it copying as empty; Cut copies then clears the selection, or deletes whole rows when the selection consists of them; Paste fills every selected cell from a single value, or pastes a block at the selection's top-left cell, ignoring columns beyond the last column with a status message and appending rows beyond the last row, all as one undo step, then selects the pasted block. * Add the global Delete action: sets the selected cells to NULL. * Add the global Select All action: selects every cell of the active grid. * Add Set to NULL, Set to Empty String, and Fill Down (a copy of the selection's top row into its other rows, or the row above for a one-row selection). * Add Edit Cell in Dialog: opens the anchor cell's value in the multi-line dialog editor. * Edit a value that has a line break in the multi-line dialog editor too, however the edit starts, since a single-line editor changes or drops line breaks, and keep line feeds when an editor's text widget returns CR LF, as on Windows, unless the value already used CR LF. * Give GridAction an editing-mode hook so Copy, Cut, Paste, Delete, and Select All act on the active cell editor's text instead of the grid while it is open, matching every other command's rule of doing nothing while editing. * Route clipboard reads and writes through DatasetGridContext instead of the actions constructing their own Clipboard, keeping SWT out of the core-facing action logic and the actions unit-testable. * Replace DatasetGridContext.setPendingSelection with selectRegion, which selects a block right after an operation's edit and scrolls its first cell into view: the pending selection that Insert Row, Delete Rows, Duplicate Rows, and Move Rows requested came after their edit had refreshed the grid, so it applied at the next change instead. Insert Row now selects the new row's cell in the anchor column, Delete Rows the cell left at the first deleted row, and Duplicate Rows and Move Rows the duplicated or moved block, so that a move can repeat. GridSelection gains columnSpan(). * Word the rejection of an edit that would leave a row with no values to suggest Delete Rows. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RY2t54o6QzRPkSEKyLAXTb --- .../META-INF/MANIFEST.MF | 3 +- .../core/flatxml/FlatXmlDatasetDocument.java | 2 +- .../dataset/core/tsv/TabSeparatedValues.java | 255 ++++++++ .../core/tsv/TabSeparatedValuesTest.java | 186 ++++++ .../plugin.properties | 4 + .../org.dbunit.eclipse.dataset.ui/plugin.xml | 14 + .../dataset/ui/actions/CopyAction.java | 115 ++++ .../eclipse/dataset/ui/actions/CutAction.java | 84 +++ .../dataset/ui/actions/DatasetCommandIds.java | 20 + .../dataset/ui/actions/DeleteAction.java | 86 +++ .../dataset/ui/actions/DeleteRowsAction.java | 2 +- .../ui/actions/DuplicateRowsAction.java | 4 +- .../ui/actions/EditCellInDialogAction.java | 55 ++ .../dataset/ui/actions/FillDownAction.java | 132 ++++ .../dataset/ui/actions/GridAction.java | 58 +- .../ui/actions/InsertRowAboveAction.java | 2 +- .../ui/actions/InsertRowBelowAction.java | 2 +- .../ui/actions/MoveRowsDownAction.java | 4 +- .../dataset/ui/actions/MoveRowsUpAction.java | 4 +- .../dataset/ui/actions/PasteAction.java | 204 ++++++ .../dataset/ui/actions/SelectAllAction.java | 79 +++ .../ui/actions/SetEmptyStringAction.java | 69 ++ .../dataset/ui/actions/SetNullAction.java | 82 +++ .../eclipse/dataset/ui/editor/TablesPage.java | 173 ++++- .../dataset/ui/grid/DatasetCellLabels.java | 45 +- .../eclipse/dataset/ui/grid/DatasetGrid.java | 99 ++- .../dataset/ui/grid/DatasetGridContext.java | 62 +- .../ui/grid/GridEditConfiguration.java | 15 +- .../dataset/ui/grid/GridSelection.java | 10 + .../ui/grid/TableBodyDataProvider.java | 16 +- .../dataset/ui/actions/GridActionsTest.java | 615 +++++++++++++++++- .../dataset/ui/editor/TablesPageTest.java | 53 ++ .../dataset/ui/grid/DatasetGridTest.java | 83 ++- .../dataset/ui/grid/GridEditingTest.java | 180 ++++- .../dataset/ui/grid/ModalDialogDriver.java | 177 +++++ 35 files changed, 2915 insertions(+), 79 deletions(-) create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/tsv/TabSeparatedValues.java create mode 100644 bundles/org.dbunit.eclipse.dataset.core/src/test/java/org/dbunit/eclipse/dataset/core/tsv/TabSeparatedValuesTest.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/CopyAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/CutAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DeleteAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/EditCellInDialogAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/FillDownAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/PasteAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/SelectAllAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/SetEmptyStringAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/SetNullAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/ModalDialogDriver.java 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 1451191e..20ae2673 100644 --- a/bundles/org.dbunit.eclipse.dataset.core/META-INF/MANIFEST.MF +++ b/bundles/org.dbunit.eclipse.dataset.core/META-INF/MANIFEST.MF @@ -20,4 +20,5 @@ 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.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/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java index a5de102d..dbcbec98 100644 --- a/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java +++ b/bundles/org.dbunit.eclipse.dataset.core/src/main/java/org/dbunit/eclipse/dataset/core/flatxml/FlatXmlDatasetDocument.java @@ -272,7 +272,7 @@ public void setCells(final String tableKey, final List changes) { throw new DatasetEditException( "This change would leave row " + rowIndex + " of table '" + table.getName() - + "' with no values."); + + "' with no values. Use Delete Rows to remove it instead."); } final String rewritten = StartTagRewriter.rewrite(text, element, table.getColumns(), rowChanges, Map.of(), encoder); 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/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..2b2b2e99 --- /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_acceptsCrLfLfAndCrLineEnds() + { + 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.ui/plugin.properties b/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties index 3d28ba1e..0fa7d43b 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties +++ b/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties @@ -16,3 +16,7 @@ command.deleteColumn.name = Delete Column command.addTable.name = Add Table... command.renameTable.name = Rename Table... command.deleteTable.name = Delete Table +command.setNull.name = Set to NULL +command.setEmptyString.name = Set to Empty String +command.fillDown.name = Fill Down +command.editCellInDialog.name = Edit Cell in Dialog... diff --git a/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml b/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml index 79eb81a8..0b319af0 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml +++ b/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml @@ -52,6 +52,14 @@ categoryId="org.dbunit.eclipse.dataset.ui.category"/> + + + + + + 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..e7020738 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/CopyAction.java @@ -0,0 +1,115 @@ +/* + * + * 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.model.DatasetTable; +import org.dbunit.eclipse.dataset.core.tsv.TabSeparatedValues; +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 bounding rectangle of the selection to the clipboard as tab-separated text; cells inside it + * that are not selected copy as NULL. + * + * @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("Copy"); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + copySelectedCellsToClipboard(context); + } + + /** + * Copies the bounding rectangle 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. + */ + static void copySelectedCellsToClipboard(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final DatasetTable table = + context.getDatasetDocument().getModel().findTable(selection.tableKey()).orElseThrow(); + final Set selectedCells = new HashSet<>(context.getSelectedCellPositions()); + final List> block = new ArrayList<>(); + for (int rowIndex = selection.firstRowIndex(); rowIndex <= selection.lastRowIndex(); rowIndex++) + { + final List line = new ArrayList<>(); + for (int columnIndex = selection.firstColumnIndex(); columnIndex <= selection + .lastColumnIndex(); columnIndex++) + { + final boolean isSelected = selectedCells.contains(new Point(columnIndex, rowIndex)); + line.add(isSelected ? table.getRows().get(rowIndex).getValue(columnIndex) : null); + } + block.add(line); + } + final String text = TabSeparatedValues.format(block, System.lineSeparator()); + context.writeClipboardText(text); + } + + @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..986933cb --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/CutAction.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 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. + * + * @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("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 index 9aa763a1..a26366b4 100644 --- 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 @@ -92,6 +92,26 @@ public final class DatasetCommandIds */ 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"; + 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..fc956fa2 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DeleteAction.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.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, or, while a cell editor is active, 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("Delete"); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + 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/DeleteRowsAction.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/DeleteRowsAction.java index e26223b4..586248ee 100644 --- 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 @@ -54,7 +54,7 @@ protected void runOnGrid(final DatasetGridContext context) () -> context.getDatasetDocument().deleteRows(selection.tableKey(), rowIndexes)); if (applied) { - context.setPendingSelection(columnIndex, rowIndex); + context.selectRegion(columnIndex, rowIndex, 1, 1); } } 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 index deb31d17..c3c08823 100644 --- 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 @@ -48,13 +48,13 @@ protected void runOnGrid(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 newBlockStart = selection.lastRowIndex() + 1; final boolean applied = context.executeEdit( () -> context.getDatasetDocument().duplicateRows(selection.tableKey(), rowIndexes)); if (applied) { - context.setPendingSelection(columnIndex, newBlockStart); + context.selectRegion(selection.firstColumnIndex(), newBlockStart, selection.columnSpan(), + rowIndexes.length); } } 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..111604d6 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/EditCellInDialogAction.java @@ -0,0 +1,55 @@ +/* + * + * 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; + +/** + * 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("Edit Cell in Dialog..."); + } + + @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..c24e3845 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/FillDownAction.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 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.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 +{ + private final DatasetGridContext context; + + /** + * 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); + this.context = context; + setText("Fill Down"); + setImageDescriptor(DatasetImages.getImageDescriptor(DatasetImages.IMG_FILL_DOWN)); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final DatasetTable table = + context.getDatasetDocument().getModel().findTable(selection.tableKey()).orElseThrow(); + 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("Fill Down", + () -> context.getDatasetDocument().setCells(selection.tableKey(), changes)); + } + + @Override + protected boolean isEnabledFor(final GridSelection selection) + { + if (selection.columnIndexes().isEmpty()) + { + return false; + } + for (final List selectedRows : selectedRowsByColumn(context).values()) + { + if (sourceRowIndex(selectedRows) >= 0) + { + return true; + } + } + return false; + } + + /** + * 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 index 30738ef9..6e48370c 100644 --- 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 @@ -26,7 +26,8 @@ /** * 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 does nothing while a cell editor is active. + * updates its enablement from a {@link GridSelection}, and by default needs an editable page and does + * nothing while a cell editor is active. * * @since 1.0.0 */ @@ -46,11 +47,26 @@ protected GridAction(final String commandId, final DatasetGridContext 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() { if (context.hasActiveCellEditor()) { + if (isEnabledWhileEditing()) + { + runWhileEditing(context); + } return; } runOnGrid(context); @@ -63,7 +79,9 @@ public final void run() */ public final void update(final GridSelection selection) { - setEnabled(context.isEditable() && !context.hasActiveCellEditor() && isEnabledFor(selection)); + final boolean pageAllowsThis = context.isEditable() || !changesDataset(); + final boolean editingAllowsThis = !context.hasActiveCellEditor() || isEnabledWhileEditing(); + setEnabled(pageAllowsThis && editingAllowsThis && isEnabledFor(selection)); } /** @@ -74,11 +92,43 @@ public final void update(final GridSelection selection) protected abstract void runOnGrid(DatasetGridContext context); /** - * Returns whether this command applies to a selection, on a page that is already known to be editable - * with no active cell editor. + * 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 index cd6238a4..6206cb51 100644 --- 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 @@ -62,7 +62,7 @@ protected void runOnGrid(final DatasetGridContext context) .insertRows(selection.tableKey(), rowIndex, List.of(blankRow))); if (applied) { - context.setPendingSelection(columnIndex, rowIndex); + context.selectRegion(columnIndex, rowIndex, 1, 1); } } 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 index 56270c98..4cc76edf 100644 --- 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 @@ -62,7 +62,7 @@ protected void runOnGrid(final DatasetGridContext context) .insertRows(selection.tableKey(), rowIndex, List.of(blankRow))); if (applied) { - context.setPendingSelection(columnIndex, rowIndex); + context.selectRegion(columnIndex, rowIndex, 1, 1); } } 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 index 3a1b3c76..f6844779 100644 --- 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 @@ -45,13 +45,13 @@ public MoveRowsDownAction(final DatasetGridContext context) protected void runOnGrid(final DatasetGridContext context) { final GridSelection selection = context.getSelection(); - final int columnIndex = Math.max(selection.anchorColumnIndex(), 0); final int newFirstRowIndex = selection.firstRowIndex() + 1; final boolean applied = context.executeEdit(() -> context.getDatasetDocument().moveRows( selection.tableKey(), selection.firstRowIndex(), selection.rowIndexes().size(), 1)); if (applied) { - context.setPendingSelection(columnIndex, newFirstRowIndex); + context.selectRegion(selection.firstColumnIndex(), newFirstRowIndex, selection.columnSpan(), + selection.rowIndexes().size()); } } 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 index de1f6f7f..d5048620 100644 --- 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 @@ -45,13 +45,13 @@ public MoveRowsUpAction(final DatasetGridContext context) protected void runOnGrid(final DatasetGridContext context) { final GridSelection selection = context.getSelection(); - final int columnIndex = Math.max(selection.anchorColumnIndex(), 0); final int newFirstRowIndex = selection.firstRowIndex() - 1; final boolean applied = context.executeEdit(() -> context.getDatasetDocument().moveRows( selection.tableKey(), selection.firstRowIndex(), selection.rowIndexes().size(), -1)); if (applied) { - context.setPendingSelection(columnIndex, newFirstRowIndex); + context.selectRegion(selection.firstColumnIndex(), newFirstRowIndex, selection.columnSpan(), + selection.rowIndexes().size()); } } 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..19cf89ea --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/PasteAction.java @@ -0,0 +1,204 @@ +/* + * + * 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.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +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: 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. + * + * @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("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().getModel().findTable(selection.tableKey()).orElseThrow(); + final List selectedCells = context.getSelectedCellPositions(); + if (parsedRows.size() == 1 && parsedRows.get(0).size() == 1 && selectedCells.size() > 1) + { + fillSelection(context, selection, table, selectedCells, parsedRows.get(0).get(0)); + return; + } + pasteBlock(context, selection, table, parsedRows); + } + + private void fillSelection(final DatasetGridContext context, final GridSelection selection, + final DatasetTable table, final List selectedCells, final String value) + { + final List changes = new ArrayList<>(); + for (final Point cell : selectedCells) + { + changes.add(new CellChange(cell.y, table.getColumns().get(cell.x).name(), value)); + } + context.executeMultiCellEdit("Paste", + () -> context.getDatasetDocument().setCells(selection.tableKey(), changes)); + } + + private void pasteBlock(final DatasetGridContext context, final GridSelection selection, + final DatasetTable table, final List> parsedRows) + { + final int anchorRowIndex = selection.firstRowIndex(); + final int anchorColumnIndex = selection.firstColumnIndex(); + if (anchorRowIndex < 0 || anchorColumnIndex < 0) + { + return; + } + final int columnCount = table.getColumns().size(); + final int rowCount = table.getRows().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<>(); + 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, columnCount, + sourceRow); + } + else + { + appendedRows.add(buildAppendedRow(anchorColumnIndex, columnCount, sourceRow)); + } + } + final boolean edited = context.executeMultiCellEdit("Paste", () -> context.getDatasetDocument() + .batch(() -> + { + if (!changes.isEmpty()) + { + context.getDatasetDocument().setCells(selection.tableKey(), changes); + } + if (!appendedRows.isEmpty()) + { + context.getDatasetDocument().insertRows(selection.tableKey(), rowCount, + appendedRows); + } + })); + if (!edited) + { + return; + } + context.selectRegion(anchorColumnIndex, anchorRowIndex, pastedColumnCount, parsedRows.size()); + reportIgnoredColumns(context, ignoredColumnCount); + } + + private static void reportIgnoredColumns(final DatasetGridContext context, final int ignoredColumnCount) + { + if (ignoredColumnCount > 0) + { + final String columnWord = ignoredColumnCount == 1 ? "column" : "columns"; + context.setStatusMessage( + "Ignored " + ignoredColumnCount + " pasted " + columnWord + " beyond the table's last " + + "column."); + } + } + + private static void addExistingRowChanges(final DatasetTable table, final List changes, + final int targetRowIndex, final int anchorColumnIndex, final int columnCount, + final List sourceRow) + { + 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; + } + + @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) + { + 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/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..98891ed2 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/SelectAllAction.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.actions; + +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("Select All"); + } + + @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..a797b8d7 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/SetEmptyStringAction.java @@ -0,0 +1,69 @@ +/* + * + * 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.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.swt.graphics.Point; + +/** + * 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("Set to Empty String"); + } + + @Override + protected void runOnGrid(final DatasetGridContext context) + { + final GridSelection selection = context.getSelection(); + final DatasetTable table = + context.getDatasetDocument().getModel().findTable(selection.tableKey()).orElseThrow(); + final List changes = new ArrayList<>(); + for (final Point cell : context.getSelectedCellPositions()) + { + changes.add(new CellChange(cell.y, table.getColumns().get(cell.x).name(), "")); + } + 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..353bfe2e --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/SetNullAction.java @@ -0,0 +1,82 @@ +/* + * + * 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.ui.DatasetImages; +import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; +import org.dbunit.eclipse.dataset.ui.grid.GridSelection; +import org.eclipse.swt.graphics.Point; + +/** + * Sets every selected cell to NULL. + * + * @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("Set to NULL"); + 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().getModel().findTable(selection.tableKey()).orElseThrow(); + final List changes = new ArrayList<>(); + for (final Point cell : context.getSelectedCellPositions()) + { + changes.add(new CellChange(cell.y, table.getColumns().get(cell.x).name(), null)); + } + 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/editor/TablesPage.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/TablesPage.java index 2282a7b8..8e245b9d 100644 --- 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 @@ -38,17 +38,26 @@ import org.dbunit.eclipse.dataset.ui.DatasetUiPlugin; 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.grid.DatasetGrid; import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; import org.dbunit.eclipse.dataset.ui.grid.GridSelection; @@ -69,11 +78,15 @@ import org.eclipse.jface.text.IDocument; import org.eclipse.jface.text.IDocumentListener; import org.eclipse.nebula.widgets.nattable.NatTable; +import org.eclipse.nebula.widgets.nattable.edit.editor.ICellEditor; 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.custom.StackLayout; +import org.eclipse.swt.dnd.Clipboard; +import org.eclipse.swt.dnd.TextTransfer; +import org.eclipse.swt.dnd.Transfer; import org.eclipse.swt.events.SelectionListener; import org.eclipse.swt.graphics.Color; import org.eclipse.swt.graphics.Font; @@ -87,6 +100,7 @@ import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Label; import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Text; import org.eclipse.swt.widgets.ToolBar; import org.eclipse.text.undo.DocumentUndoManagerRegistry; import org.eclipse.text.undo.IDocumentUndoManager; @@ -186,8 +200,30 @@ public void documentChanged(final DocumentEvent event) private final DeleteTableAction deleteTableAction; + private final SetNullAction setNullAction; + + private final SetEmptyStringAction setEmptyStringAction; + + private final FillDownAction fillDownAction; + + private final EditCellInDialogAction editCellInDialogAction; + + 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 IContextActivation contextActivation; @@ -266,6 +302,15 @@ public void documentChanged(final DocumentEvent event) addTableAction = new AddTableAction(this); renameTableAction = new RenameTableAction(this); deleteTableAction = new DeleteTableAction(this); + setNullAction = new SetNullAction(this); + setEmptyStringAction = new SetEmptyStringAction(this); + fillDownAction = new FillDownAction(this); + editCellInDialogAction = new EditCellInDialogAction(this); + cutAction = new CutAction(this); + copyAction = new CopyAction(this); + pasteAction = new PasteAction(this); + deleteAction = new DeleteAction(this); + selectAllAction = new SelectAllAction(this); gridActions.add(insertRowAboveAction); gridActions.add(insertRowBelowAction); gridActions.add(deleteRowsAction); @@ -278,6 +323,15 @@ public void documentChanged(final DocumentEvent event) gridActions.add(addTableAction); gridActions.add(renameTableAction); gridActions.add(deleteTableAction); + gridActions.add(setNullAction); + gridActions.add(setEmptyStringAction); + gridActions.add(fillDownAction); + gridActions.add(editCellInDialogAction); + 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); @@ -314,6 +368,10 @@ public void documentChanged(final DocumentEvent event) datasetDocument.addModelListener(event -> reconcile()); undoAction = new DocumentUndoAction(this::sourceDocument, false, this::hasActiveCellEditor); redoAction = new DocumentUndoAction(this::sourceDocument, true, this::hasActiveCellEditor); + 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); operationHistoryListener = this::handleOperationHistoryEvent; OperationHistoryFactory.getOperationHistory().addOperationHistoryListener(operationHistoryListener); @@ -397,15 +455,7 @@ void dispose() */ IAction getGlobalActionHandler(final String actionDefinitionId) { - if (ActionFactory.UNDO.getId().equals(actionDefinitionId)) - { - return undoAction; - } - if (ActionFactory.REDO.getId().equals(actionDefinitionId)) - { - return redoAction; - } - return null; + return globalActionHandlers.get(actionDefinitionId); } @Override @@ -502,7 +552,23 @@ public boolean executeMultiCellEdit(final String title, final Runnable edit) @Override public void fillContextMenu(final IMenuManager menu, final String region) { - if (GridRegion.BODY.equals(region) || GridRegion.ROW_HEADER.equals(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); + } + else if (GridRegion.ROW_HEADER.equals(region)) { menu.add(insertRowAboveAction); menu.add(insertRowBelowAction); @@ -535,12 +601,13 @@ public GridSelection getSelection() } @Override - public void setPendingSelection(final int columnIndex, final int rowIndex) + public void selectRegion(final int firstColumnIndex, final int firstRowIndex, final int columnCount, + final int rowCount) { final DatasetGrid grid = activeGrid(); if (grid != null) { - grid.setPendingSelection(columnIndex, rowIndex); + grid.selectRegion(firstColumnIndex, firstRowIndex, columnCount, rowCount); } } @@ -550,6 +617,84 @@ public Shell getShell() return control.getShell(); } + @Override + public 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; + } + + @Override + public List getSelectedCellPositions() + { + final DatasetGrid grid = activeGrid(); + return grid != null ? grid.getSelectedCellPositions() : List.of(); + } + + @Override + public void selectAll() + { + final DatasetGrid grid = activeGrid(); + if (grid != null) + { + grid.selectAll(); + } + } + + @Override + public void editCellInDialog() + { + final DatasetGrid grid = activeGrid(); + if (grid != null) + { + grid.editCellInDialog(); + } + } + + @Override + public void setStatusMessage(final String message) + { + editor.getEditorSite().getActionBars().getStatusLineManager().setMessage(message); + } + + @Override + public 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(); + } + } + + @Override + public 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()) @@ -647,6 +792,10 @@ private void updateGridActionsEnablement() { action.update(selection); } + for (final GridAction action : retargetableActions) + { + action.update(selection); + } } private DatasetGrid activeGrid() 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 index 058940a3..0ded1ca0 100644 --- 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 @@ -32,8 +32,16 @@ final class DatasetCellLabels implements IConfigLabelAccumulator { static final String NULL_VALUE = "NULL_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; @@ -43,9 +51,44 @@ final class DatasetCellLabels implements IConfigLabelAccumulator public void accumulateConfigLabels(final LabelStack configLabels, final int columnPosition, final int rowPosition) { - if (bodyDataProvider.getDataValue(columnPosition, rowPosition) == null) + 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 (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 index 2a2c4abd..35b31053 100644 --- 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 @@ -20,6 +20,7 @@ */ package org.dbunit.eclipse.dataset.ui.grid; +import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -30,6 +31,7 @@ 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.grid.data.DefaultCornerDataProvider; import org.eclipse.nebula.widgets.nattable.grid.data.DefaultRowHeaderDataProvider; @@ -41,11 +43,15 @@ 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.viewport.ViewportLayer; +import org.eclipse.nebula.widgets.nattable.viewport.command.ShowCellInViewportCommand; import org.eclipse.swt.graphics.Point; import org.eclipse.swt.widgets.Composite; import org.eclipse.swt.widgets.Control; @@ -73,8 +79,6 @@ public final class DatasetGrid private DatasetTable currentTable; - private PositionCoordinate pendingSelection; - /** * Assembles a NatTable for one table. * @@ -200,6 +204,52 @@ public void addSelectionListener(final Runnable 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(); @@ -225,15 +275,35 @@ public void tableRenamed(final String newTableKey) } /** - * Requests the cell to select the next time the table changes, instead of the old selection anchor - * clamped to the new bounds. + * 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 columnIndex The column of the cell to select. - * @param rowIndex The row of the cell to select. + * @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 setPendingSelection(final int columnIndex, final int rowIndex) + public void selectRegion(final int firstColumnIndex, final int firstRowIndex, final int columnCount, + final int rowCount) { - pendingSelection = new PositionCoordinate(selectionLayer, columnIndex, rowIndex); + 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)); } /** @@ -263,19 +333,12 @@ private void reselect(final PositionCoordinate oldAnchor, final DatasetTable new { final int rowCount = newTable.getRows().size(); final int columnCount = newTable.getColumns().size(); - if (rowCount == 0 || columnCount == 0) - { - pendingSelection = null; - return; - } - final PositionCoordinate target = pendingSelection != null ? pendingSelection : oldAnchor; - pendingSelection = null; - if (target == null || target.columnPosition < 0 || target.rowPosition < 0) + if (rowCount == 0 || columnCount == 0 || oldAnchor.columnPosition < 0 || oldAnchor.rowPosition < 0) { return; } - final int column = Math.min(target.columnPosition, columnCount - 1); - final int row = Math.min(target.rowPosition, rowCount - 1); + 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); } 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 index 5665ef43..d4a21ec7 100644 --- 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 @@ -20,9 +20,13 @@ */ 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. @@ -102,12 +106,16 @@ public interface DatasetGridContext GridSelection getSelection(); /** - * Requests the cell to select on the active grid the next time it refreshes. + * 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 columnIndex The column of the cell to select. - * @param rowIndex The row of the cell to select. + * @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 setPendingSelection(int columnIndex, int rowIndex); + void selectRegion(int firstColumnIndex, int firstRowIndex, int columnCount, int rowCount); /** * Returns the shell to parent dialogs on. @@ -144,4 +152,50 @@ public interface DatasetGridContext * @param tableKey The key of the table about to be added. */ void expectNewTableSelected(String tableKey); + + /** + * 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(); + + /** + * 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); + + /** + * 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/GridEditConfiguration.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/GridEditConfiguration.java index ecbf13f3..cf3c3c22 100644 --- 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 @@ -38,8 +38,6 @@ */ final class GridEditConfiguration extends AbstractRegistryConfiguration { - private static final String MULTI_LINE_VALUE = "MULTI_LINE_VALUE"; - private final DatasetGridContext context; GridEditConfiguration(final DatasetGridContext context) @@ -61,9 +59,14 @@ public boolean isEditable(final int columnIndex, final int rowIndex) configRegistry.registerConfigAttribute(EditConfigAttributes.CELL_EDITOR, new TextCellEditor(true, true), DisplayMode.EDIT); configRegistry.registerConfigAttribute(EditConfigAttributes.CELL_EDITOR, - new MultiLineTextCellEditor(false), DisplayMode.EDIT, MULTI_LINE_VALUE); + new MultiLineTextCellEditor(false), 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, + new MultiLineTextCellEditor(false), DisplayMode.EDIT, DatasetCellLabels.EDIT_IN_DIALOG); configRegistry.registerConfigAttribute(EditConfigAttributes.OPEN_IN_DIALOG, Boolean.TRUE, - DisplayMode.EDIT, MULTI_LINE_VALUE); + DisplayMode.EDIT, DatasetCellLabels.EDIT_IN_DIALOG); configRegistry.registerConfigAttribute(EditConfigAttributes.DATA_VALIDATOR, new XmlCharacterValidator(), DisplayMode.EDIT); configRegistry.registerConfigAttribute(EditConfigAttributes.VALIDATION_ERROR_HANDLER, @@ -75,7 +78,9 @@ public boolean isEditable(final int columnIndex, final int rowIndex) // Only body cells hold dataset values; the headers and the corner keep NatTable's default converter. configRegistry.registerConfigAttribute(CellConfigAttributes.DISPLAY_CONVERTER, new NullAwareDisplayConverter(context), 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, GridRegion.BODY); + 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 index 2730c5e5..62e9e004 100644 --- 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 @@ -120,6 +120,16 @@ public boolean isContiguousRowSelection() return !rowIndexes.isEmpty() && lastRowIndex - firstRowIndex + 1 == rowIndexes.size(); } + /** + * 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); 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 index b8929009..04b8a6eb 100644 --- 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 @@ -84,8 +84,22 @@ public void setDataValue(final int columnIndex, final int rowIndex, final Object } 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, (String) newValue)))); + 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) diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java index 9ccb42b7..df64d55c 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java @@ -27,31 +27,55 @@ 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.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.DatasetColumn; import org.dbunit.eclipse.dataset.core.model.DatasetTable; +import org.dbunit.eclipse.dataset.ui.DatasetImages; import org.dbunit.eclipse.dataset.ui.dialogs.AddTableDialog; import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; import org.dbunit.eclipse.dataset.ui.grid.GridSelection; import org.eclipse.core.commands.ExecutionException; import org.eclipse.jface.action.IMenuManager; import org.eclipse.jface.dialogs.IInputValidator; +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.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.eclipse.text.undo.DocumentUndoManagerRegistry; import org.eclipse.text.undo.IDocumentUndoManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; /** - * Tests {@link InsertRowAboveAction}, {@link InsertRowBelowAction}, and {@link DeleteRowsAction} against - * the Commands specification's insert index, selection, enablement, and active-cell-editor rules. + * Tests the {@code GridAction} subclasses against the Commands and Clipboard specifications' selection, + * enablement, undo, and active-cell-editor rules. */ class GridActionsTest { + private Shell shell; + + @BeforeEach + void createShell() + { + shell = new Shell(Display.getDefault()); + } + + @AfterEach + void disposeShell() + { + shell.dispose(); + } + @Test void testInsertRowBelow_withAnchorInTheSecondRow_insertsAtIndex2AndSelectsTheNewRow() { @@ -66,10 +90,9 @@ void testInsertRowBelow_withAnchorInTheSecondRow_insertsAtIndex2AndSelectsTheNew assertThat(datasetDocument.getModel().findTable("USERS").orElseThrow().getRows()) .as("Insert Row Below must add one row.").hasSize(4); - assertThat(context.pendingSelectionRow).as("Insert Row Below must select the new row at index 2.") - .isEqualTo(2); - assertThat(context.pendingSelectionColumn).as("Insert Row Below must keep the anchor column.") - .isEqualTo(0); + 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 @@ -185,6 +208,7 @@ void testDuplicateRows_withTwoRowsSelected_insertsCopiesDirectlyAfterThemAndSele 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(); @@ -195,8 +219,8 @@ void testDuplicateRows_withTwoRowsSelected_insertsCopiesDirectlyAfterThemAndSele .isEqualTo("1"); assertThat(table.getRows().get(3).getValue(0)) .as("The duplicate block must copy the second row, in order.").isEqualTo("2"); - assertThat(context.pendingSelectionRow).as("Duplicate Rows must select the new block's first row.") - .isEqualTo(2); + assertThat(context.selectedRegion).as("Duplicate Rows must select the new block.") + .isEqualTo(new Rectangle(0, 2, 1, 2)); } @Test @@ -206,6 +230,7 @@ void testMoveRowsUp_withAContiguousBlockSelected_movesItUpAndKeepsItSelected() 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(); @@ -216,8 +241,8 @@ void testMoveRowsUp_withAContiguousBlockSelected_movesItUpAndKeepsItSelected() 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.pendingSelectionRow).as("Move Rows Up must keep the block selected.") - .isEqualTo(0); + assertThat(context.selectedRegion).as("Move Rows Up must keep the moved block selected.") + .isEqualTo(new Rectangle(0, 0, 1, 2)); } @Test @@ -227,6 +252,7 @@ void testMoveRowsDown_withAContiguousBlockSelected_movesItDownAndKeepsItSelected 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(); @@ -237,8 +263,8 @@ void testMoveRowsDown_withAContiguousBlockSelected_movesItDownAndKeepsItSelected 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.pendingSelectionRow).as("Move Rows Down must keep the block selected.") - .isEqualTo(1); + assertThat(context.selectedRegion).as("Move Rows Down must keep the moved block selected.") + .isEqualTo(new Rectangle(0, 1, 1, 2)); } @Test @@ -714,6 +740,495 @@ boolean confirmDelete(final Shell shell, final DatasetTable table) .as("Declining the confirmation must change nothing.").isPresent(); } + @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_withANonRectangularSelection_copiesUnselectedCellsInTheRectangleAsEmpty() + { + 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)); + final CopyAction action = new CopyAction(context); + + action.run(); + + assertThat(context.clipboardText) + .as("A cell inside the bounding rectangle that is not selected must copy as empty.") + .isEqualTo("1\t" + System.lineSeparator() + "\tBob" + System.lineSeparator()); + } + + @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 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 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 testCut_ofACellRange_copiesThenSetsTheSelectedCellsToNull() + { + 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.wholeRowsSelected = false; + final CutAction action = new CutAction(context); + + action.run(); + + assertThat(context.clipboardText).as("Cut must copy the selection before clearing it.") + .isEqualTo("Alice" + System.lineSeparator() + "Bob" + System.lineSeparator()); + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows().get(0).getValue(1)).as("Cut must set the selected cells to NULL.") + .isNull(); + assertThat(table.getRows().get(1).getValue(1)).isNull(); + assertThat(table.getRows().get(2).getValue(1)).as("Cut must not touch an unselected row.") + .isEqualTo("Carol"); + assertThat(table.getRows().get(0).getValue(0)).as("Cut must not touch an unselected column.") + .isEqualTo("1"); + } + + @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"); + } + + @Test + void testDelete_setsTheSelectedCellsToNull() + { + 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 DeleteAction action = new DeleteAction(context); + + action.run(); + + final DatasetTable table = datasetDocument.getModel().findTable("USERS").orElseThrow(); + assertThat(table.getRows().get(0).getValue(1)).as("Delete must set the selected cells to NULL.") + .isNull(); + assertThat(table.getRows().get(0).getValue(0)).isEqualTo("1"); + assertThat(table.getRows().get(1).getValue(1)).as("Delete must not touch an unselected row.") + .isEqualTo("Bob"); + } + + @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"); + } + + @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(); + } + + @Test + void testSetEmptyString_setsTheSelectedCellsToTheEmptyString() + { + 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(""); + } + + @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(); + } + + @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( + ""); + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", null))); + 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 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)); + } + + @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 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"); + } + + @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); + + action.run(); + + 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_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."); + } + private static FlatXmlDatasetDocument create(final String content) { return create(new Document(content)); @@ -736,7 +1251,7 @@ DtdSource.NONE, new FlatXmlOptions(true, FlatXmlOptions.DBUNIT_DEFAULTS.columnSe return datasetDocument; } - private static final class TestContext implements DatasetGridContext + private final class TestContext implements DatasetGridContext { private final DatasetDocument datasetDocument; @@ -754,9 +1269,23 @@ private static final class TestContext implements DatasetGridContext private List rowIndexes = List.of(); - private int pendingSelectionColumn = -1; + private List columnIndexes = List.of(); + + private boolean wholeRowsSelected; - private int pendingSelectionRow = -1; + private List selectedCellPositions = List.of(); + + private Text activeCellEditorText; + + private boolean selectAllCalled; + + private boolean editCellInDialogCalled; + + private String statusMessage; + + private String clipboardText; + + private Rectangle selectedRegion; private String expectedRenameOldKey; @@ -838,21 +1367,25 @@ public GridSelection getSelection() final int columnCount = table.getColumns().size(); final int firstRow = rowIndexes.isEmpty() ? -1 : rowIndexes.get(0); final int lastRow = rowIndexes.isEmpty() ? -1 : rowIndexes.get(rowIndexes.size() - 1); + final int firstColumn = columnIndexes.isEmpty() ? -1 : columnIndexes.get(0); + final int lastColumn = + columnIndexes.isEmpty() ? -1 : columnIndexes.get(columnIndexes.size() - 1); return new GridSelection(tableKey, rowCount, columnCount, anchorColumnIndex, anchorRowIndex, - rowIndexes, List.of(), firstRow, lastRow, -1, -1, !rowIndexes.isEmpty()); + rowIndexes, columnIndexes, firstRow, lastRow, firstColumn, lastColumn, + wholeRowsSelected); } @Override - public void setPendingSelection(final int columnIndex, final int rowIndex) + public void selectRegion(final int firstColumnIndex, final int firstRowIndex, final int columnCount, + final int rowCount) { - pendingSelectionColumn = columnIndex; - pendingSelectionRow = rowIndex; + selectedRegion = new Rectangle(firstColumnIndex, firstRowIndex, columnCount, rowCount); } @Override public Shell getShell() { - return null; + return shell; } @Override @@ -880,5 +1413,47 @@ public void expectNewTableSelected(final String tableKey) { expectedNewTableKey = tableKey; } + + @Override + public Text getActiveCellEditorText() + { + return activeCellEditorText; + } + + @Override + public List getSelectedCellPositions() + { + return selectedCellPositions; + } + + @Override + public void selectAll() + { + selectAllCalled = true; + } + + @Override + public void editCellInDialog() + { + editCellInDialogCalled = true; + } + + @Override + public void setStatusMessage(final String message) + { + statusMessage = 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/editor/TablesPageTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java index 4c141771..0bad8d1b 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java @@ -357,6 +357,59 @@ void testActivate_whenTheTablesPageIsActive_activatesTheContextAndHandlers() thr } } + @Test + void testInsertRowBelowAndDeleteRows_throughTheirCommands_selectTheRowAtTheirPosition() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", + ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final TablesPage tablesPage = editor.getTablesPage(); + final IHandlerService handlerService = + editor.getEditorSite().getService(IHandlerService.class); + tablesPage.selectRegion(1, 0, 1, 1); + + handlerService.executeCommand(DatasetCommandIds.INSERT_ROW_BELOW, null); + + assertThat(tablesPage.getSelection()) + .as("Insert Row Below must select the new row's cell in the anchor column.") + .isEqualTo(new GridSelection("USERS", 3, 2, 1, 1, List.of(1), List.of(1), 1, 1, 1, 1, + false)); + + tablesPage.selectRegion(0, 0, 2, 2); + handlerService.executeCommand(DatasetCommandIds.DELETE_ROWS, null); + + assertThat(tablesPage.getSelection()) + .as("Delete Rows must select the row that takes the place of the deleted ones.") + .isEqualTo(new GridSelection("USERS", 1, 2, 0, 0, List.of(0), List.of(0), 0, 0, 0, 0, + false)); + } + } + + @Test + void testMoveRowsUp_throughItsCommand_keepsTheMovedRowsSelected() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", + ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final TablesPage tablesPage = editor.getTablesPage(); + final IHandlerService handlerService = + editor.getEditorSite().getService(IHandlerService.class); + tablesPage.selectRegion(0, 1, 1, 2); + + handlerService.executeCommand(DatasetCommandIds.MOVE_ROWS_UP, null); + + assertThat(tablesPage.getSelection()) + .as("Move Rows Up must keep the moved rows selected, so that they can move again.") + .isEqualTo(new GridSelection("T", 3, 1, 0, 0, List.of(0, 1), List.of(0), 0, 1, 0, 0, + true)); + } + } + @Test void testGlobalActionHandler_undoAfterAGridEdit_restoresTheTextAndRedoReappliesIt() throws Exception { diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java index 48b30fae..43095da2 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java @@ -37,16 +37,20 @@ import org.eclipse.jface.text.IDocument; import org.eclipse.nebula.widgets.nattable.NatTable; import org.eclipse.nebula.widgets.nattable.config.CellConfigAttributes; +import org.eclipse.nebula.widgets.nattable.config.IConfigRegistry; import org.eclipse.nebula.widgets.nattable.coordinate.PositionCoordinate; import org.eclipse.nebula.widgets.nattable.data.convert.IDisplayConverter; +import org.eclipse.nebula.widgets.nattable.edit.EditConfigAttributes; import org.eclipse.nebula.widgets.nattable.grid.GridRegion; import org.eclipse.nebula.widgets.nattable.layer.LabelStack; import org.eclipse.nebula.widgets.nattable.layer.cell.CellDisplayConversionUtils; import org.eclipse.nebula.widgets.nattable.layer.cell.ILayerCell; import org.eclipse.nebula.widgets.nattable.style.DisplayMode; +import org.eclipse.swt.graphics.Point; import org.eclipse.swt.layout.FillLayout; 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; @@ -127,6 +131,26 @@ void testDisplayConverter_whenValueHasLineBreaks_showsTheLineBreakGlyph() .isEqualTo("line1⏎line2⏎line3⏎line4"); } + @Test + void testCellLabels_ofAValueWithALineBreak_openItInTheDialogEditor() + { + final FlatXmlDatasetDocument datasetDocument = + create("" + + ""); + final DatasetGrid grid = new DatasetGrid(shell, new TestContext(datasetDocument), "USERS"); + final List multiLineLabels = grid.cellLabelsFor(1, 0).getLabels(); + final IConfigRegistry configRegistry = grid.getNatTable().getConfigRegistry(); + + assertThat(multiLineLabels).as("A value with a line break must get the multi-line label.") + .containsExactly("MULTI_LINE_VALUE"); + assertThat(grid.cellLabelsFor(1, 1).getLabels()).as("A single-line value must get no label.") + .isEmpty(); + assertThat(configRegistry.getConfigAttribute(EditConfigAttributes.OPEN_IN_DIALOG, DisplayMode.EDIT, + multiLineLabels)) + .as("A multi-line value must open in the dialog editor, which keeps its line breaks.") + .isTrue(); + } + @Test void testDisplayText_ofTheCornerHeadersAndBody_showsTheNullDisplayTextOnlyForNullBodyCells() { @@ -202,6 +226,24 @@ void testTableChanged_whenARowIsInserted_updatesTheRowCountAndKeepsTheAnchor() .isEqualTo(List.of(0, 1)); } + @Test + void testSelectRegion_reachingBeyondTheTable_selectsTheBlockClampedToTheTable() + { + final FlatXmlDatasetDocument datasetDocument = create("" + + ""); + final DatasetGrid grid = new DatasetGrid(shell, new TestContext(datasetDocument), "USERS"); + grid.tableChanged(datasetDocument.getModel().findTable("USERS").orElseThrow()); + processEvents(); + + grid.selectRegion(1, 1, 5, 5); + + assertThat(selectedCells(grid)).as("The block must be clamped to the table's last column and row.") + .containsExactlyInAnyOrder(List.of(1, 1), List.of(1, 2)); + final PositionCoordinate anchor = grid.getSelectionLayer().getSelectionAnchor(); + assertThat(List.of(anchor.columnPosition, anchor.rowPosition)) + .as("The block's first cell must become the selection anchor.").isEqualTo(List.of(1, 1)); + } + private static List> selectedCells(final DatasetGrid grid) { final List> cells = new ArrayList<>(); @@ -303,7 +345,8 @@ public GridSelection getSelection() } @Override - public void setPendingSelection(final int columnIndex, final int rowIndex) + public void selectRegion(final int firstColumnIndex, final int firstRowIndex, final int columnCount, + final int rowCount) { } @@ -332,5 +375,43 @@ public void cancelExpectedNewTableSelected() public void expectNewTableSelected(final String tableName) { } + + @Override + public Text getActiveCellEditorText() + { + return null; + } + + @Override + public List getSelectedCellPositions() + { + return List.of(); + } + + @Override + public void selectAll() + { + } + + @Override + public void editCellInDialog() + { + } + + @Override + public void setStatusMessage(final String message) + { + } + + @Override + public void writeClipboardText(final String text) + { + } + + @Override + public String readClipboardText() + { + return null; + } } } diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java index 434270a0..f677729d 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java @@ -24,6 +24,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.nio.charset.StandardCharsets; +import java.util.List; import org.dbunit.eclipse.dataset.core.dtd.DtdSource; import org.dbunit.eclipse.dataset.core.edit.DatasetDocument; @@ -41,9 +42,11 @@ import org.eclipse.nebula.widgets.nattable.selection.command.SelectCellCommand; import org.eclipse.swt.SWT; import org.eclipse.swt.events.KeyEvent; +import org.eclipse.swt.graphics.Point; import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Event; 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; @@ -56,15 +59,19 @@ class GridEditingTest { private Shell shell; + private ModalDialogDriver dialogDriver; + @BeforeEach void createShell() { shell = new Shell(Display.getDefault()); + dialogDriver = new ModalDialogDriver(shell); } @AfterEach void disposeShell() { + dialogDriver.disarm(); shell.dispose(); } @@ -130,7 +137,8 @@ void testSetDataValue_whenTheEditWouldEmptyARow_leavesTheDocumentUnchangedAndSet .isEqualTo(originalText); assertThat(context.lastErrorMessage) .as("A rejected edit must set the status line error message.") - .isEqualTo("This change would leave row 0 of table 'USERS' with no values."); + .isEqualTo("This change would leave row 0 of table 'USERS' with no values. Use Delete " + + "Rows to remove it instead."); } @Test @@ -147,6 +155,36 @@ void testSetDataValue_whenTheInputIsReadOnly_rejectsTheEdit() assertThat(document.get()).as("A read-only input must reject the edit.").isEqualTo(originalText); } + @Test + void testSetDataValue_whenTheEditorTurnedLineFeedsIntoCrLf_keepsTheLineFeeds() + { + final String originalText = ""; + final IDocument document = new Document(originalText); + final FlatXmlDatasetDocument datasetDocument = create(document); + final TestContext context = new TestContext(datasetDocument); + final TableBodyDataProvider provider = new TableBodyDataProvider(context, "USERS"); + + provider.setDataValue(1, 0, "first\r\nsecond"); + + assertThat(document.get()) + .as("A text widget that writes CR LF must not change a value with line feeds.") + .isEqualTo(originalText); + } + + @Test + void testSetDataValue_whenTheValueUsedCrLf_keepsCrLf() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + final TestContext context = new TestContext(datasetDocument); + final TableBodyDataProvider provider = new TableBodyDataProvider(context, "USERS"); + + provider.setDataValue(1, 0, "a\r\nb\r\nc"); + + assertThat(document.get()).as("A value that used CR LF line breaks must keep them.") + .isEqualTo(""); + } + @Test void testCommit_ofAnEditThatMovesTheSelection_closesTheEditorWithoutEditingTheNextCell() { @@ -167,6 +205,40 @@ void testCommit_ofAnEditThatMovesTheSelection_closesTheEditorWithoutEditingTheNe .isEqualTo(""); } + @Test + void testCommit_whenTheTextOfTheInPlaceEditorIsCleared_storesTheEmptyString() + { + final IDocument document = new Document(""); + final NatTable natTable = openGrid(create(document), "USERS").getNatTable(); + natTable.doCommand(new SelectCellCommand(natTable, 2, 1, false, false)); + natTable.doCommand(new EditSelectionCommand(natTable, natTable.getConfigRegistry())); + final ICellEditor cellEditor = natTable.getActiveCellEditor(); + cellEditor.setEditorValue(""); + + cellEditor.commit(MoveDirectionEnum.NONE); + + assertThat(document.get()) + .as("Clearing the text of a cell with a value must store the empty string, not NULL.") + .isEqualTo(""); + } + + @Test + void testCommit_whenTheInPlaceEditorOfANullCellIsUntouched_keepsItNull() + { + final String originalText = ""; + final IDocument document = new Document(originalText); + final NatTable natTable = openGrid(create(document), "USERS").getNatTable(); + natTable.doCommand(new SelectCellCommand(natTable, 2, 2, false, false)); + natTable.doCommand(new EditSelectionCommand(natTable, natTable.getConfigRegistry())); + final ICellEditor cellEditor = natTable.getActiveCellEditor(); + + cellEditor.commit(MoveDirectionEnum.NONE); + + assertThat(document.get()) + .as("Committing the editor of a NULL cell without typing anything must keep it NULL.") + .isEqualTo(originalText); + } + @Test void testCommitActiveCellEditor_whileACellIsBeingEdited_writesItsValueAndClosesTheEditor() { @@ -186,6 +258,71 @@ void testCommitActiveCellEditor_whileACellIsBeingEdited_writesItsValueAndClosesT .isEqualTo(""); } + @Test + void testEditCellInDialog_whenAnEmptyStringIsConfirmedUnchanged_keepsTheEmptyString() + { + final String originalText = ""; + final IDocument document = new Document(originalText); + final DatasetGrid grid = openGrid(create(document), "USERS"); + grid.selectRegion(1, 0, 1, 1); + dialogDriver.confirmNextDialog(); + + grid.editCellInDialog(); + + assertThat(dialogDriver.hasConfirmed()).as("The dialog must open and be confirmed.").isTrue(); + assertThat(document.get()) + .as("Confirming the dialog without a change must keep the empty string, not make it NULL.") + .isEqualTo(originalText); + } + + @Test + void testEditCellInDialog_whenTheDialogTextIsCleared_storesTheEmptyString() + { + final IDocument document = new Document(""); + final DatasetGrid grid = openGrid(create(document), "USERS"); + grid.selectRegion(1, 0, 1, 1); + dialogDriver.confirmNextDialogWithText(""); + + grid.editCellInDialog(); + + assertThat(dialogDriver.hasConfirmed()).as("The dialog must open and be confirmed.").isTrue(); + assertThat(document.get()) + .as("Clearing the text of a cell with a value must store the empty string, not NULL.") + .isEqualTo(""); + } + + @Test + void testEditCellInDialog_whenTheCellIsNullAndTheDialogIsConfirmedUnchanged_keepsItNull() + { + final String originalText = ""; + final IDocument document = new Document(originalText); + final DatasetGrid grid = openGrid(create(document), "USERS"); + grid.selectRegion(1, 1, 1, 1); + dialogDriver.confirmNextDialog(); + + grid.editCellInDialog(); + + assertThat(dialogDriver.hasConfirmed()).as("The dialog must open and be confirmed.").isTrue(); + assertThat(document.get()) + .as("Confirming the dialog of a NULL cell without typing anything must keep it NULL.") + .isEqualTo(originalText); + } + + @Test + void testEditCellInDialog_whenTheDialogTextIsChanged_storesTheNewValue() + { + final IDocument document = new Document(""); + final DatasetGrid grid = openGrid(create(document), "USERS"); + grid.selectRegion(1, 0, 1, 1); + dialogDriver.confirmNextDialogWithText("Bob"); + + grid.editCellInDialog(); + + assertThat(dialogDriver.hasConfirmed()).as("The dialog must open and be confirmed.").isTrue(); + assertThat(document.get()).as("The text entered in the dialog must reach the document.") + .isEqualTo(""); + } + @Test void testValidate_whenTheValueContainsANonXmlCharacter_throwsNamingTheCharacter() { @@ -386,7 +523,8 @@ public GridSelection getSelection() } @Override - public void setPendingSelection(final int columnIndex, final int rowIndex) + public void selectRegion(final int firstColumnIndex, final int firstRowIndex, final int columnCount, + final int rowCount) { } @@ -415,5 +553,43 @@ public void cancelExpectedNewTableSelected() public void expectNewTableSelected(final String tableName) { } + + @Override + public Text getActiveCellEditorText() + { + return null; + } + + @Override + public List getSelectedCellPositions() + { + return List.of(); + } + + @Override + public void selectAll() + { + } + + @Override + public void editCellInDialog() + { + } + + @Override + public void setStatusMessage(final String message) + { + } + + @Override + public void writeClipboardText(final String text) + { + } + + @Override + public String readClipboardText() + { + return null; + } } } diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/ModalDialogDriver.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/ModalDialogDriver.java new file mode 100644 index 00000000..c61987d9 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/ModalDialogDriver.java @@ -0,0 +1,177 @@ +/* + * + * 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.Arrays; +import java.util.Optional; +import java.util.function.Consumer; + +import org.eclipse.swt.SWT; +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.swt.widgets.Text; + +/** + * Confirms the modal dialog that the code under test opens on a parent shell. A test cannot confirm it + * itself, because the call that opens a modal dialog does not return until the dialog is closed. The dialog + * must have one text field and a default button that confirms it. + */ +final class ModalDialogDriver +{ + private static final int POLL_MILLIS = 20; + + private static final int GIVE_UP_MILLIS = 10_000; + + private final Shell parent; + + private final Display display; + + private final Runnable giveUp = this::closeLeftOverDialogs; + + private boolean armed; + + private boolean confirmed; + + ModalDialogDriver(final Shell parent) + { + this.parent = parent; + display = parent.getDisplay(); + } + + /** + * Confirms the next dialog that opens on the parent shell, with the text of its text field as it opens. + */ + void confirmNextDialog() + { + arm(text -> + { + }); + } + + /** + * Replaces the text of the text field of the next dialog that opens on the parent shell, and confirms + * the dialog. + * + * @param newText The text to enter into the dialog's text field. + */ + void confirmNextDialogWithText(final String newText) + { + arm(text -> text.setText(newText)); + } + + /** + * Tells whether a dialog opened and was confirmed. + * + * @return True once the dialog's default button was pressed. + */ + boolean hasConfirmed() + { + return confirmed; + } + + /** + * Stops waiting for a dialog; a test calls it when it ends, so that it cannot confirm a dialog of a + * later test. + */ + void disarm() + { + armed = false; + display.timerExec(-1, giveUp); + } + + private void arm(final Consumer edit) + { + armed = true; + display.timerExec(GIVE_UP_MILLIS, giveUp); + display.asyncExec(() -> poll(edit)); + } + + private void poll(final Consumer edit) + { + if (!armed || parent.isDisposed()) + { + return; + } + final Shell[] dialogs = parent.getShells(); + final Optional dialog = Arrays.stream(dialogs).filter(ModalDialogDriver::isConfirmable) + .findFirst(); + if (dialog.isEmpty()) + { + display.timerExec(POLL_MILLIS, () -> poll(edit)); + return; + } + confirm(dialog.get(), edit); + } + + private void confirm(final Shell dialog, final Consumer edit) + { + armed = false; + final Optional text = findText(dialog); + edit.accept(text.orElseThrow(() -> new AssertionError("The dialog has no text field."))); + confirmed = true; + dialog.getDefaultButton().notifyListeners(SWT.Selection, new Event()); + } + + /** + * Closes every dialog still open when waiting for one took too long, or when confirming one did not + * close it, so that the test fails instead of blocking the build in the dialog's event loop. + */ + private void closeLeftOverDialogs() + { + armed = false; + if (parent.isDisposed()) + { + return; + } + for (final Shell dialog : parent.getShells()) + { + dialog.dispose(); + } + } + + private static boolean isConfirmable(final Shell dialog) + { + return dialog.isVisible() && dialog.getDefaultButton() != null; + } + + private static Optional findText(final Control control) + { + if (control instanceof Text) + { + return Optional.of((Text) control); + } + if (control instanceof Composite) + { + for (final Control child : ((Composite) control).getChildren()) + { + final Optional found = findText(child); + if (found.isPresent()) + { + return found; + } + } + } + return Optional.empty(); + } +} From a1b80a5cd7f43e06877f79707fe15487078f0540 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Fri, 25 Sep 2026 07:11:52 -0500 Subject: [PATCH 024/170] feat(editor): Use DTD declarations for tables and columns * Give a declared-only table's tab the tooltip "Declared in the DTD; no rows" instead of the generic row and column count. * Add a column header tooltip, "Declared in the DTD; no values yet", for a column the DTD declares that has no values. * Warn in the Add Column dialog that dbUnit reads a flat XML dataset's columns from its DTD, when the table already has columns declared there. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr --- .../dataset/ui/actions/AddColumnAction.java | 24 ++- .../eclipse/dataset/ui/editor/TablesPage.java | 4 +- .../dataset/ui/grid/ColumnHeaderTooltip.java | 78 ++++++++++ .../eclipse/dataset/ui/grid/DatasetGrid.java | 8 + .../dataset/ui/actions/GridActionsTest.java | 3 +- .../dataset/ui/editor/DtdVisibilityTest.java | 142 ++++++++++++++++++ .../dataset/ui/grid/DatasetGridTest.java | 56 +++++++ 7 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ColumnHeaderTooltip.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/DtdVisibilityTest.java 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 index ba36c2d8..67181769 100644 --- 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 @@ -41,6 +41,9 @@ */ public class AddColumnAction extends GridAction { + private static final String DTD_WARNING = "\n\ndbUnit reads a flat XML dataset's columns from its " + + "DTD; add this column there too, or it will have no effect."; + /** * Creates the action. * @@ -65,7 +68,7 @@ protected void runOnGrid(final DatasetGridContext context) existingNames.add(column.name()); } final String columnName = - openNameDialog(context.getShell(), new DatasetNameValidator(existingNames)); + openNameDialog(context.getShell(), new DatasetNameValidator(existingNames), table); if (columnName == null) { return; @@ -78,14 +81,29 @@ protected void runOnGrid(final DatasetGridContext context) * * @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) + String openNameDialog(final Shell shell, final IInputValidator validator, final DatasetTable table) { - final InputDialog dialog = new InputDialog(shell, "Add Column", "Column name:", "", validator); + final InputDialog dialog = + new InputDialog(shell, "Add Column", 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 ? "Column name:" + DTD_WARNING : "Column name:"; + } + @Override protected boolean isEnabledFor(final GridSelection selection) { 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 index 8e245b9d..8b22daa0 100644 --- 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 @@ -962,8 +962,8 @@ private CTabItem moveTab(final CTabItem oldItem, final int index) private void updateTab(final CTabItem item, final DatasetTable table, final DatasetModel model) { item.setText(table.getName()); - item.setToolTipText( - table.getRows().size() + " rows, " + table.getColumns().size() + " columns"); + item.setToolTipText(table.isDeclaredOnly() ? "Declared in the DTD; no rows" + : table.getRows().size() + " rows, " + table.getColumns().size() + " columns"); item.setFont(table.getRows().isEmpty() ? italicFont() : null); item.setImage(problemImage(model.getProblems(table.getKey()))); } 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..3c85ff24 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/ColumnHeaderTooltip.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.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.swt.widgets.Event; + +/** + * Shows the tooltip "Declared in the DTD; no values yet" over a column header whose column is declared in + * the DTD but has no values. + * + * @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 is not declared in the DTD without values. + */ + String textForColumn(final int columnIndex) + { + final DatasetColumn column = bodyDataProvider.getColumn(columnIndex); + return column != null && column.declared() && !column.hasValues() + ? "Declared in the DTD; no values yet" : null; + } +} 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 index 35b31053..c52a7cee 100644 --- 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 @@ -75,6 +75,8 @@ public final class DatasetGrid private final ColumnHeaderLabels columnHeaderLabels; + private final ColumnHeaderTooltip columnHeaderTooltip; + private final ColumnWidths columnWidths = new ColumnWidths(); private DatasetTable currentTable; @@ -126,6 +128,7 @@ public DatasetGrid(final Composite parent, final DatasetGridContext context, fin natTable.configure(); natTable.setTheme(context.isDarkTheme() ? new DarkNatTableThemeConfiguration() : new ModernNatTableThemeConfiguration()); + columnHeaderTooltip = new ColumnHeaderTooltip(natTable, bodyDataProvider); wireContextMenu(context); } @@ -172,6 +175,11 @@ TableBodyDataProvider getBodyDataProvider() return bodyDataProvider; } + ColumnHeaderTooltip getColumnHeaderTooltip() + { + return columnHeaderTooltip; + } + SelectionLayer getSelectionLayer() { return selectionLayer; diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java index df64d55c..341fd732 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java @@ -305,7 +305,8 @@ void testAddColumn_whenTheDialogReturnsAName_addsThePendingColumn() final AddColumnAction action = new AddColumnAction(context) { @Override - String openNameDialog(final Shell shell, final IInputValidator validator) + String openNameDialog(final Shell shell, final IInputValidator validator, + final DatasetTable table) { return "EMAIL"; } diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/DtdVisibilityTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/DtdVisibilityTest.java new file mode 100644 index 00000000..e8966dec --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/DtdVisibilityTest.java @@ -0,0 +1,142 @@ +/* + * + * 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.nio.charset.StandardCharsets; +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.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.resources.IFile; +import org.eclipse.swt.SWT; +import org.eclipse.swt.custom.CTabItem; +import org.junit.jupiter.api.Test; + +/** + * Tests that the Tables page makes a dataset's DTD declarations visible, end to end through a real editor. + */ +class DtdVisibilityTest +{ + @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 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/grid/DatasetGridTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java index 43095da2..f0df1517 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java @@ -49,6 +49,7 @@ import org.eclipse.swt.graphics.Point; import org.eclipse.swt.layout.FillLayout; import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Event; import org.eclipse.swt.widgets.Shell; import org.eclipse.swt.widgets.Text; import org.junit.jupiter.api.AfterEach; @@ -184,6 +185,61 @@ void testColumnHeaderLabels_whenColumnIsPending_getsThePendingColumnLabel() .isTrue(); } + @Test + void testColumnHeaderTooltip_overTheEmptyAreaBeyondTheCells_hasNoText() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + final DatasetGrid grid = new DatasetGrid(shell, new TestContext(datasetDocument), "USERS"); + grid.tableChanged(datasetDocument.getModel().findTable("USERS").orElseThrow()); + shell.layout(); + processEvents(); + final Event hover = new Event(); + hover.x = grid.getNatTable().getClientArea().width - 2; + hover.y = grid.getNatTable().getClientArea().height - 2; + + assertThat(grid.getColumnHeaderTooltip().getText(hover)) + .as("Hovering beyond the last column and row must show no tooltip instead of failing.") + .isNull(); + } + + @Test + void testColumnHeaderTooltip_forEvent_usesTheHoveredColumnsOwnTooltipNotTheNextColumns() + { + final FlatXmlDatasetDocument datasetDocument = create( + "\n\n" + + "\n]>\n" + + "\n \n\n"); + final DatasetGrid grid = new DatasetGrid(shell, new TestContext(datasetDocument), "USERS"); + shell.layout(); + processEvents(); + final NatTable natTable = grid.getNatTable(); + final Event hover = new Event(); + hover.x = natTable.getStartXOfColumnPosition(1); + hover.y = natTable.getStartYOfRowPosition(0) + 2; + + assertThat(grid.getColumnHeaderTooltip().getText(hover)) + .as("Hovering the first body column's header (ID, which has a value) must show no " + + "tooltip, not the DTD-declared-without-values text that belongs to NAME.") + .isNull(); + } + + @Test + void testColumnHeaderTooltip_forADeclaredColumnWithoutValues_showsTheDtdText() + { + final FlatXmlDatasetDocument datasetDocument = create( + "\n\n" + + "\n]>\n" + + "\n \n\n"); + final DatasetGrid grid = new DatasetGrid(shell, new TestContext(datasetDocument), "USERS"); + + final String idText = grid.getColumnHeaderTooltip().textForColumn(0); + final String nameText = grid.getColumnHeaderTooltip().textForColumn(1); + + assertThat(idText).as("A declared column with a value must get no tooltip.").isNull(); + assertThat(nameText).as("A declared column without values must explain why it is empty.") + .isEqualTo("Declared in the DTD; no values yet"); + } + @Test void testTableChanged_whenStructureIsUnchanged_keepsTheSelection() { From 966f77b108bba08c09c9b6f98d61845d0531ba08 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Fri, 25 Sep 2026 07:32:04 -0500 Subject: [PATCH 025/170] feat(editor): Show dataset problems in the Tables page * Add ProblemsSection: a "Problems (N)" header and a table of every model problem's severity, table, and message, below the grid, hidden when there are none. Double-click selects the problem's table and cell, or, for a document-level problem, shows the range on the Source page. * List a column's problems in its header tooltip, alongside the existing DTD note. The warning and error images on column headers and table tabs already existed; this makes them explorable. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr --- .../dataset/ui/editor/ProblemsSection.java | 245 ++++++++++++++++++ .../eclipse/dataset/ui/editor/TablesPage.java | 39 +++ .../dataset/ui/grid/ColumnHeaderTooltip.java | 41 ++- .../eclipse/dataset/ui/grid/DatasetGrid.java | 11 + .../dataset/ui/editor/ProblemDisplayTest.java | 94 +++++++ .../ui/editor/ProblemsSectionTest.java | 166 ++++++++++++ .../dataset/ui/editor/TablesPageTest.java | 35 +++ .../dataset/ui/grid/DatasetGridTest.java | 56 ++++ 8 files changed, 682 insertions(+), 5 deletions(-) create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/ProblemsSection.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/ProblemDisplayTest.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/ProblemsSectionTest.java 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..ea9cc351 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/ProblemsSection.java @@ -0,0 +1,245 @@ +/* + * + * 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.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.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("Problems (" + 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/TablesPage.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/TablesPage.java index 8b22daa0..3faf3016 100644 --- 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 @@ -137,6 +137,8 @@ final class TablesPage implements DatasetGridContext private final StackLayout contentStackLayout; + private final ProblemsSection problemsSection; + private final Composite blankComposite; private final CTabFolder tabFolder; @@ -264,6 +266,8 @@ public void documentChanged(final DocumentEvent event) 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); @@ -717,6 +721,11 @@ ErrorBanner getErrorBanner() return errorBanner; } + ProblemsSection getProblemsSection() + { + return problemsSection; + } + boolean isShowingBlankState() { return blankComposite.equals(contentStackLayout.topControl); @@ -815,6 +824,32 @@ private DatasetGrid activeGrid() return null; } + void selectProblem(final DatasetProblem problem) + { + if (problem.tableKey() == null) + { + editor.showOnSourcePage(problem.offset(), problem.length()); + return; + } + final CTabItem item = tabsByKey.get(problem.tableKey()); + if (item != null) + { + tabFolder.setSelection(item); + updateGridActionsEnablement(); + } + final DatasetGrid grid = gridsByKey.get(problem.tableKey()); + if (grid == null || problem.columnName() == null) + { + return; + } + final int columnIndex = datasetDocument.getModel().findTable(problem.tableKey()) + .map(table -> table.getColumnIndex(problem.columnName())).orElse(-1); + if (columnIndex >= 0) + { + grid.selectCell(columnIndex, Math.max(problem.rowIndex(), 0)); + } + } + private void scheduleRefresh() { if (refreshScheduled) @@ -867,6 +902,9 @@ else if (model.getTables().isEmpty()) contentStack.layout(); updateBanner(model); + // A blank document's only problem is its missing root element, which the blank state explains. + final DatasetModel listedModel = datasetDocument.isBlank() ? DatasetModel.EMPTY : model; + problemsSection.update(listedModel); updateGridActionsEnablement(); } @@ -886,6 +924,7 @@ private void reconcileTabs(final DatasetModel model) if (item == null) { grid = new DatasetGrid(tabFolder, this, key); + grid.selectCell(0, 0); grid.addSelectionListener(this::updateGridActionsEnablement); gridsByKey.put(key, grid); item = new CTabItem(tabFolder, SWT.NONE, index); 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 index 3c85ff24..c2bbcf68 100644 --- 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 @@ -20,7 +20,11 @@ */ 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.eclipse.jface.window.DefaultToolTip; import org.eclipse.nebula.widgets.nattable.NatTable; import org.eclipse.nebula.widgets.nattable.grid.GridRegion; @@ -28,8 +32,8 @@ import org.eclipse.swt.widgets.Event; /** - * Shows the tooltip "Declared in the DTD; no values yet" over a column header whose column is declared in - * the DTD but has no values. + * Shows a column header's problems and why a column has no values, when it is declared in the DTD without + * values or pending, as a tooltip. * * @since 1.0.0 */ @@ -67,12 +71,39 @@ protected String getText(final Event event) * 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 is not declared in the DTD without values. + * @return The tooltip text, or null when the column has no problems, is not declared in the DTD + * without values, and is not pending. */ String textForColumn(final int columnIndex) { final DatasetColumn column = bodyDataProvider.getColumn(columnIndex); - return column != null && column.declared() && !column.hasValues() - ? "Declared in the DTD; no values yet" : null; + if (column == null) + { + return null; + } + final List lines = problemMessages(column); + if (column.declared() && !column.hasValues()) + { + lines.add("Declared in the DTD; no values yet"); + } + if (column.pending()) + { + lines.add("This column has no values yet; it is saved when at least one row has a value."); + } + return lines.isEmpty() ? null : String.join("\n", lines); + } + + 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/DatasetGrid.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGrid.java index c52a7cee..e9f565ba 100644 --- 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 @@ -196,6 +196,17 @@ public GridSelection getSelection() 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. * diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/ProblemDisplayTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/ProblemDisplayTest.java new file mode 100644 index 00000000..e0897761 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/ProblemDisplayTest.java @@ -0,0 +1,94 @@ +/* + * + * 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.List; + +import org.dbunit.eclipse.dataset.core.edit.CellChange; +import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +import org.dbunit.eclipse.dataset.core.model.DatasetProblem; +import org.eclipse.core.resources.IFile; +import org.junit.jupiter.api.Test; + +/** + * Tests that the Tables page displays the dataset model's problems: column header and tab decoration, the + * problems section, double-click navigation, and clearing once fixed. + */ +class ProblemDisplayTest +{ + @Test + void testProblemDisplay_endToEnd_decoratesTheTabListsTheProblemAndSelectsTheCellOnDoubleClick() + throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", + ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final TablesPage tablesPage = editor.getTablesPage(); + final FlatXmlDatasetDocument datasetDocument = editor.getDatasetDocument(); + + assertThat(tablesPage.getTabFolder().getItem(0).getImage()) + .as("A table with a problem must show a warning or error image on its tab.") + .isNotNull(); + assertThat(tablesPage.getProblemsSection().getControl().getVisible()) + .as("A dataset with a problem must show the problems section.").isTrue(); + assertThat(tablesPage.getProblemsSection().getHeaderText()) + .as("The header must count the problems.").isEqualTo("Problems (1)"); + + final DatasetProblem problem = datasetDocument.getModel().getProblems().get(0); + tablesPage.selectProblem(problem); + + assertThat(tablesPage.getTabFolder().getSelectionIndex()) + .as("Selecting the problem must select its table's tab.").isEqualTo(0); + assertThat(tablesPage.getSelection().anchorColumnIndex()) + .as("Selecting the problem must select its column.").isEqualTo(1); + assertThat(tablesPage.getSelection().anchorRowIndex()) + .as("Selecting the problem must select its row.").isEqualTo(problem.rowIndex()); + + datasetDocument.setCells("USERS", List.of(new CellChange(0, "NAME", "Alice"))); + UiTestWorkspace.processEvents(); + + assertThat(datasetDocument.getModel().getProblems()) + .as("Fixing the first row must remove the problem.").isEmpty(); + assertThat(tablesPage.getProblemsSection().getControl().getVisible()) + .as("With no problems, the problems section must hide again.").isFalse(); + assertThat(tablesPage.getTabFolder().getItem(0).getImage()) + .as("With no problems, the tab image must clear.").isNull(); + } + } + + @Test + void testProblemDisplay_whenFileIsBlank_hidesTheProblemsSection() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("blank.xml", ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.openInDatasetEditor(file); + + assertThat(editor.getTablesPage().getProblemsSection().getControl().getVisible()) + .as("A blank file must not list its missing root element, as the blank state explains it.") + .isFalse(); + } + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/ProblemsSectionTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/ProblemsSectionTest.java new file mode 100644 index 00000000..ee6ff21d --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/ProblemsSectionTest.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.editor; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +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.core.model.ProblemSeverity; +import org.eclipse.swt.graphics.Image; +import org.eclipse.swt.layout.GridLayout; +import org.eclipse.swt.widgets.Display; +import org.eclipse.swt.widgets.Shell; +import org.eclipse.swt.widgets.Table; +import org.eclipse.ui.ISharedImages; +import org.eclipse.ui.PlatformUI; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link ProblemsSection}: its height follows the number of problems, up to six rows, the message + * column takes the width the other columns leave, and each row shows its severity's image and its table's + * name. + */ +class ProblemsSectionTest +{ + private Shell shell; + + private ProblemsSection section; + + @BeforeEach + void createSection() + { + shell = new Shell(Display.getDefault()); + shell.setLayout(new GridLayout()); + shell.setSize(800, 600); + section = new ProblemsSection(shell, problem -> + { + }); + shell.open(); + UiTestWorkspace.processEvents(); + } + + @AfterEach + void disposeShell() + { + shell.dispose(); + } + + @Test + void testUpdate_withOneProblem_showsOneRowWithTheMessageColumnFillingTheWidth() + { + section.update(model(problems(1))); + UiTestWorkspace.processEvents(); + + final Table table = section.getTable(); + final int itemHeight = table.getItemHeight(); + assertThat(table.getClientArea().height) + .as("One problem must show one whole row, and no more than half of another.") + .isBetween(itemHeight, itemHeight + itemHeight / 2); + int columnsWidth = 0; + for (int column = 0; column < table.getColumnCount(); column++) + { + columnsWidth += table.getColumn(column).getWidth(); + } + assertThat(columnsWidth).as("The message column must take the width that the other columns leave.") + .isGreaterThan(table.getClientArea().width - 30); + } + + @Test + void testUpdate_withMoreThanSixProblems_showsSixRows() + { + section.update(model(problems(8))); + UiTestWorkspace.processEvents(); + + final Table table = section.getTable(); + final int itemHeight = table.getItemHeight(); + assertThat(table.getClientArea().height) + .as("More than six problems must show six whole rows, and no more than half of another.") + .isBetween(6 * itemHeight, 6 * itemHeight + itemHeight / 2); + } + + @Test + void testUpdate_withProblemsOfEachSeverity_showsEachSeveritysImage() + { + section.update(model(List.of(problem(ProblemSeverity.ERROR), problem(ProblemSeverity.WARNING), + problem(ProblemSeverity.INFO)))); + UiTestWorkspace.processEvents(); + + final Table table = section.getTable(); + final List images = new ArrayList<>(); + for (int row = 0; row < table.getItemCount(); row++) + { + images.add(table.getItem(row).getImage(0)); + } + assertThat(images).as("Each problem must show the image of its severity.").containsExactly( + sharedImage(ISharedImages.IMG_OBJS_ERROR_TSK), sharedImage(ISharedImages.IMG_OBJS_WARN_TSK), + sharedImage(ISharedImages.IMG_OBJS_INFO_TSK)); + } + + @Test + void testUpdate_withAProblemOfATable_showsTheTableNameAsItsTabDoes() + { + section.update(model(problems(1))); + UiTestWorkspace.processEvents(); + + assertThat(section.getTable().getItem(0).getText(1)) + .as("The table column must show the table's name, not its case-folded key.") + .isEqualTo("users"); + } + + private static DatasetModel model(final List problems) + { + final DatasetTable users = new DatasetTable("USERS", "users", List.of(), List.of(), false); + return new DatasetModel(List.of(users), problems, true); + } + + private static List problems(final int count) + { + final List problems = new ArrayList<>(); + for (int i = 0; i < count; i++) + { + problems.add(new DatasetProblem(ProblemCode.COLUMN_NOT_IN_FIRST_ROW, ProblemSeverity.WARNING, + "Column \"C" + i + "\" of table \"USERS\" is missing from the first element, so dbUnit " + + "ignores its value in this row.", + "USERS", "C" + i, 1, 0, 0)); + } + return problems; + } + + private static DatasetProblem problem(final ProblemSeverity severity) + { + final String message = severity + " problem"; + return new DatasetProblem(ProblemCode.COLUMN_NOT_IN_FIRST_ROW, severity, message, "USERS", "ID", 1, 0, + 0); + } + + private static Image sharedImage(final String key) + { + return PlatformUI.getWorkbench().getSharedImages().getImage(key); + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java index 0bad8d1b..be145cd1 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/TablesPageTest.java @@ -555,6 +555,41 @@ void testTablesPage_afterTheEditorInputChanges_undoesAndRefreshesWithTheNewDocum } } + @Test + void testTablesPage_whenOpened_selectsTheFirstCellOfTheFirstTable() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", + ""); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final TablesPage tablesPage = editor.getTablesPage(); + + assertThat(tablesPage.getSelection()) + .as("Opening a dataset must select the first cell of the first table.") + .isEqualTo(new GridSelection("USERS", 2, 2, 0, 0, List.of(0), List.of(0), 0, 0, 0, 0, + false)); + } + } + + @Test + void testTablesPage_whenATableHasNoRows_hasNoInitialSelection() throws Exception + { + try (UiTestWorkspace workspace = new UiTestWorkspace()) + { + final IFile file = workspace.createFile("dataset.xml", + "\n\n" + + "\n]>\n\n\n"); + final FlatXmlDatasetEditor editor = (FlatXmlDatasetEditor) workspace.open(file); + final TablesPage tablesPage = editor.getTablesPage(); + + assertThat(tablesPage.getSelection()) + .as("A table without rows must start with no cell selected.") + .isEqualTo(new GridSelection("ORDERS", 0, 1, -1, -1, List.of(), List.of(), -1, -1, -1, + -1, false)); + } + } + private static IDocument sourceDocument(final FlatXmlDatasetEditor editor) { final ITextEditor sourceEditor = editor.getSourceEditor(); diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java index f0df1517..5425c730 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java @@ -240,6 +240,37 @@ void testColumnHeaderTooltip_forADeclaredColumnWithoutValues_showsTheDtdText() .isEqualTo("Declared in the DTD; no values yet"); } + @Test + void testColumnHeaderTooltip_forAPendingColumn_explainsWhenTheColumnIsSaved() + { + final FlatXmlDatasetDocument datasetDocument = create(""); + datasetDocument.addColumn("USERS", "EXTRA"); + final DatasetGrid grid = new DatasetGrid(shell, new TestContext(datasetDocument), "USERS"); + + final String idText = grid.getColumnHeaderTooltip().textForColumn(0); + final String extraText = grid.getColumnHeaderTooltip().textForColumn(1); + + assertThat(idText).as("A column with values must get no tooltip.").isNull(); + assertThat(extraText).as("A pending column must explain that it is saved once a row has a value.") + .isEqualTo("This column has no values yet; it is saved when at least one row has a value."); + } + + @Test + void testColumnHeaderLabelsAndTooltip_whenAColumnIsMissingFromTheFirstRow_warnAndListTheProblem() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final DatasetGrid grid = new DatasetGrid(shell, new TestContext(datasetDocument), "USERS"); + + assertThat(grid.columnHeaderLabelsFor(0).hasLabel("COLUMN_WARNING")) + .as("A column with no problems must not get the warning label.").isFalse(); + assertThat(grid.columnHeaderLabelsFor(1).hasLabel("COLUMN_WARNING")) + .as("A column missing from the first row must get the warning label.").isTrue(); + assertThat(grid.getColumnHeaderTooltip().textForColumn(1)) + .as("The column header tooltip must list the problem.") + .contains("Column \"NAME\" of table \"USERS\" is missing from the first element"); + } + @Test void testTableChanged_whenStructureIsUnchanged_keepsTheSelection() { @@ -300,6 +331,31 @@ void testSelectRegion_reachingBeyondTheTable_selectsTheBlockClampedToTheTable() .as("The block's first cell must become the selection anchor.").isEqualTo(List.of(1, 1)); } + @Test + void testSelectCell_inARowBelowTheVisibleRows_scrollsItIntoView() + { + final StringBuilder text = new StringBuilder(""); + for (int row = 1; row <= 100; row++) + { + text.append(""); + } + final FlatXmlDatasetDocument datasetDocument = create(text.append("").toString()); + final DatasetGrid grid = new DatasetGrid(shell, new TestContext(datasetDocument), "USERS"); + grid.tableChanged(datasetDocument.getModel().findTable("USERS").orElseThrow()); + shell.layout(); + processEvents(); + + grid.selectCell(0, 90); + + final NatTable natTable = grid.getNatTable(); + final List visibleRowIndexes = new ArrayList<>(); + for (int position = 1; position < natTable.getRowCount(); position++) + { + visibleRowIndexes.add(natTable.getRowIndexByPosition(position)); + } + assertThat(visibleRowIndexes).as("Selecting a cell must scroll its row into view.").contains(90); + } + private static List> selectedCells(final DatasetGrid grid) { final List> cells = new ArrayList<>(); From 9d76fd6a969168f9a86b96a81c10e7bc95462b5e Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Fri, 25 Sep 2026 12:11:37 -0500 Subject: [PATCH 026/170] feat(editor): Synchronize selection between Tables and Source pages * Add PageSelectionSync to select and reveal the current cell's range on the Source page when leaving the Tables page, and to resolve the Source page's caret back to a cell when returning, unless the Source selection is unchanged. * Add a Show in Source command and context menu action that jumps from the active cell to its range on the Source page. Refs: 2 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NZiGLBMSQ3aYQBwwqFG9yr Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U5nNyaSgyKqUwLFUVEGNKv --- .../plugin.properties | 1 + .../org.dbunit.eclipse.dataset.ui/plugin.xml | 5 + .../dataset/ui/actions/DatasetCommandIds.java | 5 + .../ui/actions/ShowInSourceAction.java | 61 +++++++++ .../dataset/ui/editor/PageSelectionSync.java | 100 +++++++++++++++ .../eclipse/dataset/ui/editor/TablesPage.java | 64 ++++++++-- .../dataset/ui/grid/DatasetGridContext.java | 5 + .../dataset/ui/actions/GridActionsTest.java | 38 ++++++ .../ui/editor/PageSelectionSyncTest.java | 116 ++++++++++++++++++ .../dataset/ui/grid/DatasetGridTest.java | 5 + .../dataset/ui/grid/GridEditingTest.java | 5 + 11 files changed, 398 insertions(+), 7 deletions(-) create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/ShowInSourceAction.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/PageSelectionSync.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/PageSelectionSyncTest.java diff --git a/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties b/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties index 0fa7d43b..ae5d6643 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties +++ b/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties @@ -20,3 +20,4 @@ command.setNull.name = Set to NULL command.setEmptyString.name = Set to Empty String command.fillDown.name = Fill Down command.editCellInDialog.name = Edit Cell in Dialog... +command.showInSource.name = Show in Source diff --git a/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml b/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml index 0b319af0..d30ec4d0 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml +++ b/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml @@ -60,6 +60,8 @@ categoryId="org.dbunit.eclipse.dataset.ui.category"/> + + 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 index a26366b4..802d2137 100644 --- 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 @@ -112,6 +112,11 @@ public final class DatasetCommandIds */ 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/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..c5780762 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/actions/ShowInSourceAction.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 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("Show in Source"); + } + + @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/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/TablesPage.java b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/editor/TablesPage.java index 3faf3016..03fc3f6b 100644 --- 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 @@ -31,6 +31,7 @@ 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.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; @@ -58,6 +59,7 @@ 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.DatasetGrid; import org.dbunit.eclipse.dataset.ui.grid.DatasetGridContext; import org.dbunit.eclipse.dataset.ui.grid.GridSelection; @@ -77,6 +79,9 @@ 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.nebula.widgets.nattable.NatTable; import org.eclipse.nebula.widgets.nattable.edit.editor.ICellEditor; import org.eclipse.nebula.widgets.nattable.grid.GridRegion; @@ -139,6 +144,8 @@ final class TablesPage implements DatasetGridContext private final ProblemsSection problemsSection; + private final PageSelectionSync pageSelectionSync; + private final Composite blankComposite; private final CTabFolder tabFolder; @@ -210,6 +217,8 @@ public void documentChanged(final DocumentEvent event) private final EditCellInDialogAction editCellInDialogAction; + private final ShowInSourceAction showInSourceAction; + private final CutAction cutAction; private final CopyAction copyAction; @@ -252,6 +261,8 @@ public void documentChanged(final DocumentEvent event) 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); @@ -310,6 +321,7 @@ public void documentChanged(final DocumentEvent event) setEmptyStringAction = new SetEmptyStringAction(this); fillDownAction = new FillDownAction(this); editCellInDialogAction = new EditCellInDialogAction(this); + showInSourceAction = new ShowInSourceAction(this); cutAction = new CutAction(this); copyAction = new CopyAction(this); pasteAction = new PasteAction(this); @@ -331,6 +343,7 @@ public void documentChanged(final DocumentEvent event) gridActions.add(setEmptyStringAction); gridActions.add(fillDownAction); gridActions.add(editCellInDialogAction); + gridActions.add(showInSourceAction); retargetableActions.add(cutAction); retargetableActions.add(copyAction); retargetableActions.add(pasteAction); @@ -417,10 +430,12 @@ void activate() refreshPending = false; datasetDocument.refresh(); } + pageSelectionSync.onActivate().ifPresent(this::selectCellAddress); } void deactivate() { + pageSelectionSync.onDeactivate(currentCellAddress()); active = false; final IContextService contextService = editor.getEditorSite().getService(IContextService.class); contextService.deactivateContext(contextActivation); @@ -571,6 +586,7 @@ public void fillContextMenu(final IMenuManager menu, final String region) menu.add(setEmptyStringAction); menu.add(fillDownAction); menu.add(editCellInDialogAction); + menu.add(showInSourceAction); } else if (GridRegion.ROW_HEADER.equals(region)) { @@ -665,6 +681,18 @@ public void editCellInDialog() } } + @Override + public void showInSource() + { + final CellAddress address = currentCellAddress(); + if (address == null) + { + return; + } + datasetDocument.locate(address) + .ifPresent(region -> editor.showOnSourcePage(region.getOffset(), region.getLength())); + } + @Override public void setStatusMessage(final String message) { @@ -831,23 +859,45 @@ void selectProblem(final DatasetProblem problem) editor.showOnSourcePage(problem.offset(), problem.length()); return; } - final CTabItem item = tabsByKey.get(problem.tableKey()); + final int columnIndex = problem.columnName() == null ? -1 + : datasetDocument.getModel().findTable(problem.tableKey()) + .map(table -> table.getColumnIndex(problem.columnName())).orElse(-1); + selectCellAddress(new CellAddress(problem.tableKey(), Math.max(problem.rowIndex(), 0), columnIndex)); + } + + private void selectCellAddress(final CellAddress address) + { + final CTabItem item = tabsByKey.get(address.tableKey()); if (item != null) { tabFolder.setSelection(item); updateGridActionsEnablement(); } - final DatasetGrid grid = gridsByKey.get(problem.tableKey()); - if (grid == null || problem.columnName() == null) + final DatasetGrid grid = gridsByKey.get(address.tableKey()); + if (grid == null || address.columnIndex() < 0) { return; } - final int columnIndex = datasetDocument.getModel().findTable(problem.tableKey()) - .map(table -> table.getColumnIndex(problem.columnName())).orElse(-1); - if (columnIndex >= 0) + grid.selectCell(address.columnIndex(), address.rowIndex()); + } + + private CellAddress currentCellAddress() + { + final GridSelection selection = getSelection(); + if (selection.tableKey() == null || selection.anchorRowIndex() < 0 + || selection.anchorColumnIndex() < 0) { - grid.selectCell(columnIndex, Math.max(problem.rowIndex(), 0)); + return null; } + return new CellAddress(selection.tableKey(), selection.anchorRowIndex(), + selection.anchorColumnIndex()); + } + + private IRegion sourceSelectionRegion() + { + final ITextSelection selection = + (ITextSelection) editor.getSourceEditor().getSelectionProvider().getSelection(); + return new Region(selection.getOffset(), selection.getLength()); } private void scheduleRefresh() 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 index d4a21ec7..dbdbf2a5 100644 --- 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 @@ -178,6 +178,11 @@ public interface DatasetGridContext */ 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. * diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java index 341fd732..01acbe06 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/actions/GridActionsTest.java @@ -987,6 +987,36 @@ void testEditCellInDialog_run_opensTheAnchorCellInADialogEditor() .as("Edit Cell in Dialog must open the anchor cell's editor.").isTrue(); } + @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(); + } + @Test void testFillDown_withAMultiRowSelection_copiesTheTopRowIntoTheOtherSelectedRows() { @@ -1282,6 +1312,8 @@ private final class TestContext implements DatasetGridContext private boolean editCellInDialogCalled; + private boolean showInSourceCalled; + private String statusMessage; private String clipboardText; @@ -1439,6 +1471,12 @@ public void editCellInDialog() editCellInDialogCalled = true; } + @Override + public void showInSource() + { + showInSourceCalled = true; + } + @Override public void setStatusMessage(final String message) { diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/PageSelectionSyncTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/PageSelectionSyncTest.java new file mode 100644 index 00000000..4074a1cd --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/PageSelectionSyncTest.java @@ -0,0 +1,116 @@ +/* + * + * 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.ArrayList; +import java.util.List; +import java.util.Optional; + +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.eclipse.jface.text.Document; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.Region; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link PageSelectionSync} against the Page Synchronization rules. + */ +class PageSelectionSyncTest +{ + @Test + void testOnDeactivate_withASelectedCell_selectsAndRevealsItsAttributeValueOnSource() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + final List revealed = new ArrayList<>(); + final PageSelectionSync sync = new PageSelectionSync(() -> new Region(0, 0), + (offset, length) -> revealed.add(new int[] { offset, length }), datasetDocument); + + sync.onDeactivate(new CellAddress("USERS", 0, 1)); + + assertThat(revealed).as("Leaving with a selected cell must select and reveal it once.").hasSize(1); + final int offset = revealed.get(0)[0]; + final int length = revealed.get(0)[1]; + assertThat(document.get().substring(offset, offset + length)) + .as("The revealed range must be the cell's attribute value.").isEqualTo("Alice"); + } + + @Test + void testOnActivate_whenTheSourceSelectionIsUnchanged_keepsTheGridSelectionEvenForANullCell() + { + final FlatXmlDatasetDocument datasetDocument = + create(""); + final int[] currentSelection = new int[2]; + final PageSelectionSync sync = new PageSelectionSync( + () -> new Region(currentSelection[0], currentSelection[1]), (offset, length) -> + { + currentSelection[0] = offset; + currentSelection[1] = length; + }, datasetDocument); + + sync.onDeactivate(new CellAddress("USERS", 1, 1)); + + final Optional result = sync.onActivate(); + + assertThat(result) + .as("An unchanged source selection must keep the grid selection, even for a NULL cell " + + "whose range is just its element name.") + .isEmpty(); + } + + @Test + void testOnActivate_whenTheSourceSelectionMoved_returnsTheCellAtTheNewCaretOffset() + { + final IDocument document = new Document(""); + final FlatXmlDatasetDocument datasetDocument = create(document); + final int nameValueOffset = document.get().indexOf("Alice"); + final PageSelectionSync sync = new PageSelectionSync(() -> new Region(nameValueOffset, 0), + (offset, length) -> + { + }, datasetDocument); + + sync.onDeactivate(new CellAddress("USERS", 0, 0)); + + final Optional result = sync.onActivate(); + + assertThat(result).as("A caret moved into a different attribute must select that cell.") + .contains(new CellAddress("USERS", 0, 1)); + } + + private static FlatXmlDatasetDocument create(final String content) + { + return create(new Document(content)); + } + + private static FlatXmlDatasetDocument create(final IDocument document) + { + final FlatXmlDatasetDocument datasetDocument = new FlatXmlDatasetDocument(document, DtdSource.NONE, + FlatXmlOptions.DBUNIT_DEFAULTS, () -> StandardCharsets.UTF_8); + datasetDocument.refresh(); + return datasetDocument; + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java index 5425c730..91ecf749 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/DatasetGridTest.java @@ -510,6 +510,11 @@ public void editCellInDialog() { } + @Override + public void showInSource() + { + } + @Override public void setStatusMessage(final String message) { diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java index f677729d..c31d951e 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/grid/GridEditingTest.java @@ -576,6 +576,11 @@ public void editCellInDialog() { } + @Override + public void showInSource() + { + } + @Override public void setStatusMessage(final String message) { From 70bc4146480fb3cdcbb5444e65034c7ecc078f07 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Fri, 25 Sep 2026 13:26:19 -0500 Subject: [PATCH 027/170] feat(source): Color XML syntax on the Source page * Add XmlDocumentSetupParticipant, registered for the flat XML content type, which installs a partitioning of comments, processing instructions, the DOCTYPE, and tags on every dataset document, whichever editor opens it. A DOCTYPE with an internal subset ends at its first >. * Add XmlSourceViewerConfiguration for the Source page: tags color their element names, attribute names, and quoted values, and comments, processing instructions, and the DOCTYPE each take one color. * Take the colors from theme color definitions in a dbUnit Dataset Editor category of Colors and Fonts, with a stylesheet for the dark theme; XmlTokenColors redraws open Source pages when a color or the theme changes. Refs: 2 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U5nNyaSgyKqUwLFUVEGNKv --- .../META-INF/MANIFEST.MF | 3 +- .../build.properties | 3 +- .../css/e4-dark_dbunit_dataset.css | 8 + .../plugin.properties | 11 + .../org.dbunit.eclipse.dataset.ui/plugin.xml | 39 +++ .../ui/editor/FlatXmlSourceEditor.java | 52 +++- .../source/XmlDocumentSetupParticipant.java | 60 +++++ .../ui/source/XmlPartitionScanner.java | 157 +++++++++++ .../source/XmlSourceViewerConfiguration.java | 124 +++++++++ .../dataset/ui/source/XmlTagScanner.java | 97 +++++++ .../dataset/ui/source/XmlTokenColors.java | 127 +++++++++ .../ui/editor/FlatXmlSourceEditorTest.java | 249 ++++++++++++++++++ .../ui/source/XmlPartitionScannerTest.java | 142 ++++++++++ .../dataset/ui/source/XmlTagScannerTest.java | 111 ++++++++ .../dataset/ui/source/XmlTokenColorsTest.java | 128 +++++++++ 15 files changed, 1307 insertions(+), 4 deletions(-) create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/css/e4-dark_dbunit_dataset.css create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/XmlDocumentSetupParticipant.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/XmlPartitionScanner.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/XmlSourceViewerConfiguration.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/XmlTagScanner.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/XmlTokenColors.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/FlatXmlSourceEditorTest.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/source/XmlPartitionScannerTest.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/source/XmlTagScannerTest.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/source/XmlTokenColorsTest.java diff --git a/bundles/org.dbunit.eclipse.dataset.ui/META-INF/MANIFEST.MF b/bundles/org.dbunit.eclipse.dataset.ui/META-INF/MANIFEST.MF index 53f7d076..f4aa31b4 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/META-INF/MANIFEST.MF +++ b/bundles/org.dbunit.eclipse.dataset.ui/META-INF/MANIFEST.MF @@ -10,14 +10,15 @@ Bundle-ActivationPolicy: lazy Bundle-Activator: org.dbunit.eclipse.dataset.ui.DatasetUiPlugin Automatic-Module-Name: org.dbunit.eclipse.dataset.ui Require-Bundle: org.dbunit.eclipse.dataset.core;bundle-version="[1.0.0,2.0.0)", + org.eclipse.core.filebuffers;bundle-version="3.8.0", org.eclipse.core.filesystem;bundle-version="1.10.0", org.eclipse.core.runtime;bundle-version="3.31.0", org.eclipse.core.resources;bundle-version="3.20.0", org.eclipse.jface.text;bundle-version="3.25.0", + org.eclipse.ui.ide;bundle-version="3.22.0", org.eclipse.ui;bundle-version="3.206.0", org.eclipse.ui.workbench;bundle-version="3.132.0", org.eclipse.ui.editors;bundle-version="3.17.0", - org.eclipse.ui.ide;bundle-version="3.22.0", org.eclipse.ui.workbench.texteditor;bundle-version="3.17.0", org.eclipse.nebula.widgets.nattable.core;bundle-version="[2.7.0,3.0.0)" Import-Package: org.eclipse.osgi.util;version="1.1.0", diff --git a/bundles/org.dbunit.eclipse.dataset.ui/build.properties b/bundles/org.dbunit.eclipse.dataset.ui/build.properties index 18888a74..91767b1b 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/build.properties +++ b/bundles/org.dbunit.eclipse.dataset.ui/build.properties @@ -10,4 +10,5 @@ bin.includes = META-INF/,\ .,\ plugin.properties,\ plugin.xml,\ - icons/ + icons/,\ + css/ diff --git a/bundles/org.dbunit.eclipse.dataset.ui/css/e4-dark_dbunit_dataset.css b/bundles/org.dbunit.eclipse.dataset.ui/css/e4-dark_dbunit_dataset.css new file mode 100644 index 00000000..dbce49d7 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/css/e4-dark_dbunit_dataset.css @@ -0,0 +1,8 @@ +IEclipsePreferences#org-eclipse-ui-workbench:org-dbunit-eclipse-dataset-ui { /* pseudo attribute added to allow contributions without replacing this node, see Bug 466075 */ + preferences: + 'org.dbunit.eclipse.dataset.ui.source.tagColor=121,171,255' + 'org.dbunit.eclipse.dataset.ui.source.attributeNameColor=190,174,255' + 'org.dbunit.eclipse.dataset.ui.source.attributeValueColor=206,145,120' + 'org.dbunit.eclipse.dataset.ui.source.commentColor=106,153,85' + 'org.dbunit.eclipse.dataset.ui.source.declarationColor=160,160,160' +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties b/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties index ae5d6643..3d67a8bb 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties +++ b/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties @@ -21,3 +21,14 @@ command.setEmptyString.name = Set to Empty String command.fillDown.name = Fill Down command.editCellInDialog.name = Edit Cell in Dialog... command.showInSource.name = Show in Source +theme.category.label = dbUnit Dataset Editor +theme.tagColor.label = Source tag color +theme.tagColor.description = The color of tag delimiters and element (table) names on the Source page. +theme.attributeNameColor.label = Source attribute name color +theme.attributeNameColor.description = The color of attribute (column) names on the Source page. +theme.attributeValueColor.label = Source attribute value color +theme.attributeValueColor.description = The color of quoted attribute (cell) values on the Source page. +theme.commentColor.label = Source comment color +theme.commentColor.description = The color of comments on the Source page. +theme.declarationColor.label = Source declaration color +theme.declarationColor.description = The color of the XML declaration, other processing instructions, and the DOCTYPE on the Source page. diff --git a/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml b/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml index d30ec4d0..d0560002 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml +++ b/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml @@ -92,4 +92,43 @@ contextId="org.dbunit.eclipse.dataset.ui.tablesPageContext" schemeId="org.eclipse.ui.defaultAcceleratorConfiguration"/> + + + + + %theme.tagColor.description + + + %theme.attributeNameColor.description + + + %theme.attributeValueColor.description + + + %theme.commentColor.description + + + %theme.declarationColor.description + + + + + + + + + + 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 index 02897163..4573dbec 100644 --- 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 @@ -20,16 +20,40 @@ */ package org.dbunit.eclipse.dataset.ui.editor; +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 plain text editor over the same document, with the - * one addition the multi-page editor needs to detect changes made outside the workbench. + * The Source page of {@link FlatXmlDatasetEditor}: a text editor over the same document that colors the + * XML syntax, with the one addition the multi-page editor needs to detect changes made outside the + * workbench. * * @since 1.0.0 */ public class FlatXmlSourceEditor extends TextEditor { + private final XmlTokenColors tokenColors; + + /** + * Creates the Source page editor, which colors the XML syntax with the current theme's colors. + */ + public FlatXmlSourceEditor() + { + 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 on the nested editor's behalf, because a nested @@ -39,4 +63,28 @@ public void checkExternalModification() { safelySanityCheckState(getEditorInput()); } + + /** + * 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/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..52636cb1 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/source/XmlSourceViewerConfiguration.java @@ -0,0 +1,124 @@ +/* + * + * 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 {@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, 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) + { + final DefaultDamagerRepairer damagerRepairer = new DefaultDamagerRepairer(scanner); + 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/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..c490956c --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/editor/FlatXmlSourceEditorTest.java @@ -0,0 +1,249 @@ +/* + * + * 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.util.LinkedHashMap; +import java.util.Map; + +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.jface.resource.ColorRegistry; +import org.eclipse.jface.resource.ImageDescriptor; +import org.eclipse.jface.text.IDocumentExtension3; +import org.eclipse.swt.custom.StyleRange; +import org.eclipse.swt.custom.StyledText; +import org.eclipse.swt.graphics.RGB; +import org.eclipse.swt.widgets.Control; +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.junit.jupiter.api.Test; + +/** + * Tests the syntax coloring of {@link FlatXmlSourceEditor}, the Source page of a real dataset editor. + */ +class FlatXmlSourceEditorTest +{ + private static final String CONTENT = """ + + + + + + """; + + @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(); + } + } + + 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/source/XmlPartitionScannerTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/source/XmlPartitionScannerTest.java new file mode 100644 index 00000000..bde36e37 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/source/XmlPartitionScannerTest.java @@ -0,0 +1,142 @@ +/* + * + * 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 static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.jface.text.BadLocationException; +import org.eclipse.jface.text.Document; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.ITypedRegion; +import org.eclipse.jface.text.TextUtilities; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link XmlPartitionScanner} through the partitioner that {@link XmlDocumentSetupParticipant} + * installs. + */ +class XmlPartitionScannerTest +{ + private static final String TEXT = IDocument.DEFAULT_CONTENT_TYPE; + + private static final String COMMENT = XmlPartitionScanner.COMMENT; + + private static final String PROCESSING_INSTRUCTION = XmlPartitionScanner.PROCESSING_INSTRUCTION; + + private static final String DOCTYPE = XmlPartitionScanner.DOCTYPE; + + private static final String TAG = XmlPartitionScanner.TAG; + + @Test + void testNextToken_whenTextHasEveryConstruct_partitionsCommentProcessingInstructionDoctypeTagAndText() + throws Exception + { + final IDocument document = setUpDocument(""" + + + + + + + """); + + final List partitions = partitions(document); + + assertThat(partitions) + .as("Each construct must be one partition, and a quoted > must not end its tag.") + .isEqualTo(List.of( + new Partition(PROCESSING_INSTRUCTION, ""), + new Partition(TEXT, "\n"), + new Partition(DOCTYPE, ""), + new Partition(TEXT, "\n"), + new Partition(COMMENT, ""), + new Partition(TEXT, "\n"), + new Partition(TAG, ""), + new Partition(TEXT, "\n "), + new Partition(TAG, " b\" NAME='c > d'/>"), + new Partition(TEXT, "\n"), + new Partition(TAG, ""), + new Partition(TEXT, "\n"))); + } + + @Test + void testNextToken_whenTheLastTagHasNoEnd_extendsTheTagToTheEndOfTheText() throws Exception + { + final IDocument document = setUpDocument("\n partitions = partitions(document); + + assertThat(partitions).as("A tag without its > must extend to the end of the text.") + .isEqualTo(List.of(new Partition(TAG, ""), new Partition(TEXT, "\n"), + new Partition(TAG, " + + + """); + + document.replace(document.get().indexOf("a >") + 1, 0, "x"); + + assertThat(partitions(document)) + .as("After a change inside a quoted value, the > in that value must still not end the tag.") + .isEqualTo(List.of( + new Partition(TAG, ""), + new Partition(TEXT, "\n "), + new Partition(TAG, " b\"/>"), + new Partition(TEXT, "\n"), + new Partition(TAG, ""), + new Partition(TEXT, "\n"))); + } + + private static IDocument setUpDocument(final String text) + { + final IDocument document = new Document(text); + final XmlDocumentSetupParticipant participant = new XmlDocumentSetupParticipant(); + participant.setup(document); + return document; + } + + private static List partitions(final IDocument document) throws BadLocationException + { + final ITypedRegion[] regions = TextUtilities.computePartitioning(document, + XmlDocumentSetupParticipant.PARTITIONING, 0, document.getLength(), false); + final List partitions = new ArrayList<>(); + for (final ITypedRegion region : regions) + { + final String text = document.get(region.getOffset(), region.getLength()); + partitions.add(new Partition(region.getType(), text)); + } + return partitions; + } + + private record Partition(String type, String text) + { + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/source/XmlTagScannerTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/source/XmlTagScannerTest.java new file mode 100644 index 00000000..8af2bdf7 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/source/XmlTagScannerTest.java @@ -0,0 +1,111 @@ +/* + * + * 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 static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import org.eclipse.jface.text.Document; +import org.eclipse.jface.text.IDocument; +import org.eclipse.jface.text.rules.IToken; +import org.eclipse.jface.text.rules.Token; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link XmlTagScanner}. + */ +class XmlTagScannerTest +{ + private static final String TAG = "tag"; + + private static final String ATTRIBUTE_NAME = "attributeName"; + + private static final String ATTRIBUTE_VALUE = "attributeValue"; + + @Test + void testNextToken_forAStartTag_separatesAttributeNamesAndQuotedValuesFromTheRestOfTheTag() + { + final List runs = scan("z\"/>"); + + assertThat(runs) + .as("Attribute names and quoted values must get their own tokens; the rest is the tag token.") + .isEqualTo(List.of( + new Run(TAG, "z\""), + new Run(TAG, "/>"))); + } + + @Test + void testNextToken_forAnEndTag_returnsOnlyTheTagToken() + { + final List runs = scan(""); + + assertThat(runs).as("An end tag's element name must not become an attribute name.") + .isEqualTo(List.of(new Run(TAG, ""))); + } + + /** + * Scans a tag and joins adjacent tokens with the same data into runs, as the presentation reconciler + * colors them. + */ + private static List scan(final String tag) + { + final IDocument document = new Document(tag); + final XmlTagScanner scanner = + new XmlTagScanner(new Token(TAG), new Token(ATTRIBUTE_NAME), new Token(ATTRIBUTE_VALUE)); + scanner.setRange(document, 0, document.getLength()); + final List runs = new ArrayList<>(); + IToken token = scanner.nextToken(); + while (!token.isEOF()) + { + final String data = (String) token.getData(); + final int offset = scanner.getTokenOffset(); + final String text = tag.substring(offset, offset + scanner.getTokenLength()); + final int lastIndex = runs.size() - 1; + if (lastIndex >= 0 && runs.get(lastIndex).data().equals(data)) + { + runs.set(lastIndex, new Run(data, runs.get(lastIndex).text() + text)); + } + else + { + runs.add(new Run(data, text)); + } + token = scanner.nextToken(); + } + return runs; + } + + private record Run(String data, String text) + { + } +} diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/source/XmlTokenColorsTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/source/XmlTokenColorsTest.java new file mode 100644 index 00000000..2214daf7 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/source/XmlTokenColorsTest.java @@ -0,0 +1,128 @@ +/* + * + * 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 static org.assertj.core.api.Assertions.assertThat; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.eclipse.jface.resource.ColorRegistry; +import org.eclipse.jface.text.TextAttribute; +import org.eclipse.jface.text.rules.IToken; +import org.eclipse.swt.graphics.RGB; +import org.eclipse.ui.PlatformUI; +import org.eclipse.ui.themes.IThemeManager; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link XmlTokenColors} against the workbench's theme manager; each test restores every color it + * changes. + */ +class XmlTokenColorsTest +{ + private static final List COLOR_IDS = List.of(XmlTokenColors.TAG_COLOR, + XmlTokenColors.ATTRIBUTE_NAME_COLOR, XmlTokenColors.ATTRIBUTE_VALUE_COLOR, + XmlTokenColors.COMMENT_COLOR, XmlTokenColors.DECLARATION_COLOR); + + private static final RGB CHANGED_COLOR = new RGB(1, 2, 3); + + private final IThemeManager themeManager = PlatformUI.getWorkbench().getThemeManager(); + + private final ColorRegistry colorRegistry = themeManager.getCurrentTheme().getColorRegistry(); + + @Test + void testGetToken_forEachColorDefinition_carriesTheCurrentThemeColor() + { + final XmlTokenColors tokenColors = new XmlTokenColors(themeManager, () -> + { + }); + try + { + final Map tokenColorValues = new LinkedHashMap<>(); + final Map themeColorValues = new LinkedHashMap<>(); + for (final String colorId : COLOR_IDS) + { + tokenColorValues.put(colorId, foreground(tokenColors.getToken(colorId))); + themeColorValues.put(colorId, colorRegistry.getRGB(colorId)); + } + + assertThat(COLOR_IDS).as("The theme must define every Source page color.") + .allMatch(colorRegistry::hasValueFor); + assertThat(tokenColorValues).as("Each token must carry its theme color.") + .isEqualTo(themeColorValues); + } + finally + { + tokenColors.dispose(); + } + } + + @Test + void testThemeColorChange_whileNotDisposed_updatesTheTokenAndReportsTheChange() + { + final AtomicInteger changes = new AtomicInteger(); + final XmlTokenColors tokenColors = new XmlTokenColors(themeManager, changes::incrementAndGet); + final RGB originalColor = colorRegistry.getRGB(XmlTokenColors.TAG_COLOR); + try + { + colorRegistry.put(XmlTokenColors.TAG_COLOR, CHANGED_COLOR); + + assertThat(foreground(tokenColors.getToken(XmlTokenColors.TAG_COLOR))) + .as("A theme color change must update its token.").isEqualTo(CHANGED_COLOR); + assertThat(changes.get()).as("A theme color change must be reported once.").isEqualTo(1); + } + finally + { + colorRegistry.put(XmlTokenColors.TAG_COLOR, originalColor); + tokenColors.dispose(); + } + } + + @Test + void testThemeColorChange_afterDispose_leavesTheTokenAndReportsNothing() + { + final AtomicInteger changes = new AtomicInteger(); + final XmlTokenColors tokenColors = new XmlTokenColors(themeManager, changes::incrementAndGet); + final RGB originalColor = colorRegistry.getRGB(XmlTokenColors.TAG_COLOR); + tokenColors.dispose(); + try + { + colorRegistry.put(XmlTokenColors.TAG_COLOR, CHANGED_COLOR); + + assertThat(foreground(tokenColors.getToken(XmlTokenColors.TAG_COLOR))) + .as("A disposed instance must keep its token's color.").isEqualTo(originalColor); + assertThat(changes.get()).as("A disposed instance must not report theme changes.").isZero(); + } + finally + { + colorRegistry.put(XmlTokenColors.TAG_COLOR, originalColor); + } + } + + private static RGB foreground(final IToken token) + { + final TextAttribute textAttribute = (TextAttribute) token.getData(); + return textAttribute.getForeground().getRGB(); + } +} From 819ecf8731e5fc4a612567f3ad63dd6975c296e1 Mon Sep 17 00:00:00 2001 From: Jeff Jensen Date: Fri, 25 Sep 2026 14:13:58 -0500 Subject: [PATCH 028/170] feat(prefs): Add dbUnit dataset preferences * Add a dbUnit Dataset Editor preference page for the text shown for NULL cells, validating as if the tests enable dbUnit column sensing, and case-sensitive table names, as with dbUnit's caseSensitiveTableNames. * Reject a blank NULL display text, so that NULL cells stay distinct from empty strings. * Apply preference changes to open editors immediately: the column sensing and table name case options rebuild and revalidate the tables, and a new NULL display text repaints the grids. A change made on another thread is applied on the UI thread. Refs: 2 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U5nNyaSgyKqUwLFUVEGNKv --- .../plugin.properties | 1 + .../org.dbunit.eclipse.dataset.ui/plugin.xml | 7 + .../dbunit/eclipse/dataset/ui/Messages.java | 25 +++ .../ui/editor/FlatXmlDatasetEditor.java | 66 +++++- .../eclipse/dataset/ui/editor/TablesPage.java | 11 + .../eclipse/dataset/ui/grid/DatasetGrid.java | 9 + .../eclipse/dataset/ui/messages.properties | 5 + .../ui/preferences/DatasetPreferencePage.java | 79 +++++++ .../ui/editor/FlatXmlDatasetEditorTest.java | 142 ++++++++++++- .../DatasetPreferencePageTest.java | 197 ++++++++++++++++++ 10 files changed, 532 insertions(+), 10 deletions(-) create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/main/java/org/dbunit/eclipse/dataset/ui/preferences/DatasetPreferencePage.java create mode 100644 bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/preferences/DatasetPreferencePageTest.java diff --git a/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties b/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties index 3d67a8bb..94031a26 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties +++ b/bundles/org.dbunit.eclipse.dataset.ui/plugin.properties @@ -32,3 +32,4 @@ theme.commentColor.label = Source comment color theme.commentColor.description = The color of comments on the Source page. theme.declarationColor.label = Source declaration color theme.declarationColor.description = The color of the XML declaration, other processing instructions, and the DOCTYPE on the Source page. +preferencePage.name = dbUnit Dataset Editor diff --git a/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml b/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml index d0560002..a367c237 100644 --- a/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml +++ b/bundles/org.dbunit.eclipse.dataset.ui/plugin.xml @@ -131,4 +131,11 @@ class="org.dbunit.eclipse.dataset.ui.source.XmlDocumentSetupParticipant" contentTypeId="org.dbunit.eclipse.dataset.flatxml"/> + + + + 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 index 87023c48..aa6a6618 100644 --- 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 @@ -41,6 +41,31 @@ public final class Messages extends NLS */ 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; + static { NLS.initializeMessages(BUNDLE_NAME, Messages.class); 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 index 189b53f3..7ccd60e5 100644 --- 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 @@ -26,11 +26,14 @@ import java.nio.charset.UnsupportedCharsetException; import org.dbunit.eclipse.dataset.core.flatxml.FlatXmlDatasetDocument; +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.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.widgets.Display; import org.eclipse.ui.IEditorInput; import org.eclipse.ui.IEditorPart; @@ -154,6 +157,22 @@ public void windowOpened(final IWorkbenchWindow window) } }; + 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 TablesPage tablesPage; @@ -203,6 +222,7 @@ protected void createPages() sourceEditor.getDocumentProvider().addElementStateListener(elementStateListener); getSite().getPage().addPartListener(partListener); getSite().getWorkbenchWindow().getWorkbench().addWindowListener(windowListener); + preferenceStore.addPropertyChangeListener(preferenceListener); } @Override @@ -298,6 +318,22 @@ public T getAdapter(final Class 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) { @@ -308,6 +344,10 @@ public void dispose() documentProvider.removeElementStateListener(elementStateListener); } } + } + + private void removeWorkbenchListeners() + { if (getSite() != null && getSite().getPage() != null) { getSite().getPage().removePartListener(partListener); @@ -316,15 +356,6 @@ public void dispose() { getSite().getWorkbenchWindow().getWorkbench().removeWindowListener(windowListener); } - if (datasetDocument != null) - { - datasetDocument.dispose(); - } - if (tablesPage != null) - { - tablesPage.dispose(); - } - super.dispose(); } private void handleInputChanged() @@ -360,6 +391,23 @@ private void refreshExternalState() datasetDocument.reloadDtd(); } + 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 IEncodingSupport encodingSupport = sourceEditor.getAdapter(IEncodingSupport.class); 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 index 03fc3f6b..4a9280c6 100644 --- 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 @@ -477,6 +477,17 @@ IAction getGlobalActionHandler(final String actionDefinitionId) return globalActionHandlers.get(actionDefinitionId); } + /** + * Repaints every grid, for example after the NULL display text changed. + */ + void repaintGrids() + { + for (final DatasetGrid grid : gridsByKey.values()) + { + grid.repaint(); + } + } + @Override public void expectRename(final String oldKey, final String newKey) { 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 index e9f565ba..6adeeac1 100644 --- 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 @@ -283,6 +283,15 @@ LabelStack columnHeaderLabelsFor(final int columnIndex) return labels; } + /** + * 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. * 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 index 7fa812da..3880b929 100644 --- 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 @@ -1,2 +1,7 @@ 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 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/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 index df65596c..7ae3a9dc 100644 --- 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 @@ -24,11 +24,26 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +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.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.runtime.NullProgressMonitor; +import org.eclipse.jface.preference.IPreferenceStore; 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.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.custom.CTabItem; import org.eclipse.ui.IEditorInput; import org.eclipse.ui.IEditorPart; import org.eclipse.ui.IWorkbenchPage; @@ -36,10 +51,12 @@ import org.eclipse.ui.ide.IDE; import org.eclipse.ui.ide.IGotoMarker; 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. + * Tests {@link FlatXmlDatasetEditor} against the Editor Structure lifecycle rules and its reaction to + * preference changes. */ class FlatXmlDatasetEditorTest { @@ -47,6 +64,15 @@ class FlatXmlDatasetEditorTest private static final int SOURCE_PAGE_INDEX = 1; + @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); + } + @Test void testOpen_whenFileIsFlatXml_showsTablesAndSourcePages() throws Exception { @@ -228,4 +254,118 @@ void testGotoMarker_withAMarkerOnTheFile_activatesTheSourcePageAndSelectsItsRang .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 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(); + } } diff --git a/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/preferences/DatasetPreferencePageTest.java b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/preferences/DatasetPreferencePageTest.java new file mode 100644 index 00000000..9832e211 --- /dev/null +++ b/bundles/org.dbunit.eclipse.dataset.ui/src/test/java/org/dbunit/eclipse/dataset/ui/preferences/DatasetPreferencePageTest.java @@ -0,0 +1,197 @@ +/* + * + * 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 static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; + +import org.dbunit.eclipse.dataset.ui.DatasetUiPlugin; +import org.dbunit.eclipse.dataset.ui.Messages; +import org.eclipse.jface.preference.IPreferenceNode; +import org.eclipse.jface.preference.IPreferenceStore; +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.swt.widgets.Text; +import org.eclipse.ui.PlatformUI; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests {@link DatasetPreferencePage} through the controls a user sees. + */ +class DatasetPreferencePageTest +{ + private Shell shell; + + @BeforeEach + void createShell() + { + shell = new Shell(Display.getDefault()); + } + + @AfterEach + void disposeShellAndRestoreDefaults() + { + shell.dispose(); + final IPreferenceStore store = preferenceStore(); + store.setToDefault(PreferenceKeys.NULL_DISPLAY_TEXT); + store.setToDefault(PreferenceKeys.ASSUME_COLUMN_SENSING); + store.setToDefault(PreferenceKeys.CASE_SENSITIVE_TABLE_NAMES); + } + + @Test + void testCreatePage_ofTheRegisteredPreferenceNode_createsADatasetPreferencePage() + { + final IPreferenceNode node = + PlatformUI.getWorkbench().getPreferenceManager().find(DatasetPreferencePage.ID); + assertThat(node).as("The page must be registered in the workbench preferences.").isNotNull(); + + node.createPage(); + try + { + assertThat(node.getPage()).as("The registered page must be the dataset preference page.") + .isInstanceOf(DatasetPreferencePage.class); + } + finally + { + node.disposeResources(); + } + } + + @Test + void testCreateControl_withStoredValues_showsEveryStoredValue() + { + final IPreferenceStore store = preferenceStore(); + store.setValue(PreferenceKeys.NULL_DISPLAY_TEXT, ""); + store.setValue(PreferenceKeys.ASSUME_COLUMN_SENSING, true); + store.setValue(PreferenceKeys.CASE_SENSITIVE_TABLE_NAMES, true); + final DatasetPreferencePage page = new DatasetPreferencePage(); + + page.createControl(shell); + + assertThat(nullDisplayTextField(page).getText()) + .as("The page must show the stored NULL display text.").isEqualTo(""); + assertThat(checkBox(page, Messages.PreferencePage_assumeColumnSensing).getSelection()) + .as("The page must show the stored column sensing option.").isTrue(); + assertThat(checkBox(page, Messages.PreferencePage_caseSensitiveTableNames).getSelection()) + .as("The page must show the stored table name case option.").isTrue(); + } + + @Test + void testPerformOk_afterEditingEveryField_storesTheEditedValues() + { + final DatasetPreferencePage page = new DatasetPreferencePage(); + page.createControl(shell); + nullDisplayTextField(page).setText("NULL!"); + checkBox(page, Messages.PreferencePage_assumeColumnSensing).setSelection(true); + checkBox(page, Messages.PreferencePage_caseSensitiveTableNames).setSelection(true); + + page.performOk(); + + final IPreferenceStore store = preferenceStore(); + assertThat(store.getString(PreferenceKeys.NULL_DISPLAY_TEXT)) + .as("OK must store the edited NULL display text.").isEqualTo("NULL!"); + assertThat(store.getBoolean(PreferenceKeys.ASSUME_COLUMN_SENSING)) + .as("OK must store the edited column sensing option.").isTrue(); + assertThat(store.getBoolean(PreferenceKeys.CASE_SENSITIVE_TABLE_NAMES)) + .as("OK must store the edited table name case option.").isTrue(); + } + + @Test + void testNullDisplayTextField_whenTypedBlank_makesThePageInvalidUntilTextIsTyped() + { + final DatasetPreferencePage page = new DatasetPreferencePage(); + page.createControl(shell); + final Text field = nullDisplayTextField(page); + + type(field, " "); + + assertThat(page.isValid()).as("A blank NULL display text must make the page invalid.").isFalse(); + assertThat(page.getErrorMessage()) + .as("The page must explain why a blank NULL display text is invalid.") + .isEqualTo(Messages.PreferencePage_nullDisplayTextBlank); + + type(field, "-"); + + assertThat(page.isValid()).as("A non-blank NULL display text must make the page valid again.") + .isTrue(); + } + + private static IPreferenceStore preferenceStore() + { + return DatasetUiPlugin.getDefault().getPreferenceStore(); + } + + /** + * Replaces the field's text and releases a key, as typing does, so the field editor validates it. + */ + private static void type(final Text field, final String text) + { + field.setText(text); + field.notifyListeners(SWT.KeyUp, new Event()); + } + + private static Text nullDisplayTextField(final DatasetPreferencePage page) + { + final List fields = new ArrayList<>(); + collect(page.getControl(), Text.class, fields); + assertThat(fields).as("The page must have exactly one text field.").hasSize(1); + return fields.get(0); + } + + private static Button checkBox(final DatasetPreferencePage page, final String label) + { + final List