From ff031a5d000e49c317ee99d54881f4b010c35376 Mon Sep 17 00:00:00 2001 From: jean_de_bot Date: Thu, 11 Jun 2026 14:39:34 +0000 Subject: [PATCH 1/6] feat: add spreadsheet builder CLI --- README.adoc | 22 ++ .../spreadsheet-builder-cli.gradle | 36 ++++ .../dsl/spreadsheet/cli/SpreadsheetCli.java | 201 ++++++++++++++++++ .../spreadsheet/cli/SpreadsheetCliSpec.groovy | 164 ++++++++++++++ 4 files changed, 423 insertions(+) create mode 100644 libs/spreadsheet-builder-cli/spreadsheet-builder-cli.gradle create mode 100644 libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java create mode 100644 libs/spreadsheet-builder-cli/src/test/groovy/builders/dsl/spreadsheet/cli/SpreadsheetCliSpec.groovy diff --git a/README.adoc b/README.adoc index cdf1bff..c4ff018 100644 --- a/README.adoc +++ b/README.adoc @@ -7,5 +7,27 @@ Google Sheets are also supported indirectly (native Excel <=> Google conversion) See the link:http://spreadsheet.dsl.builders/[Full Documentation] +== Command Line + +The `spreadsheet-builder-cli` project provides a small command line wrapper around +`spreadsheet-builder-data` and `spreadsheet-builder-poi`. + +Create an Excel workbook from JSON/YAML data: + +[source,bash] +---- +spreadsheet-builder-cli create people.yml people.xlsx +---- + +Query an Excel workbook with JSON/YAML criteria: + +[source,bash] +---- +spreadsheet-builder-cli query people.xlsx query.yml +---- + +A query currently supports `sheet`, `where.column`, and optional +`where.equals` or `where.contains`, returning matching rows as JSON. + == Acknowledgement This project is inspired by http://www.craigburke.com/document-builder/[Groovy Document Builder] diff --git a/libs/spreadsheet-builder-cli/spreadsheet-builder-cli.gradle b/libs/spreadsheet-builder-cli/spreadsheet-builder-cli.gradle new file mode 100644 index 0000000..579bcfa --- /dev/null +++ b/libs/spreadsheet-builder-cli/spreadsheet-builder-cli.gradle @@ -0,0 +1,36 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2020-2026 Vladimir Orany. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +apply plugin: 'application' + +dependencies { + implementation project(':spreadsheet-builder-data') + implementation project(':spreadsheet-builder-poi') + implementation "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}" + implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:${jacksonVersion}" + runtimeOnly 'org.apache.logging.log4j:log4j-core:2.24.3' + + testImplementation project(':spreadsheet-builder-poi') +} + +application { + mainClass = 'builders.dsl.spreadsheet.cli.SpreadsheetCli' +} + +jar { + manifest.attributes 'Main-Class': application.mainClass.get() +} diff --git a/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java b/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java new file mode 100644 index 0000000..f322656 --- /dev/null +++ b/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java @@ -0,0 +1,201 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2020-2026 Vladimir Orany. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package builders.dsl.spreadsheet.cli; + +import builders.dsl.spreadsheet.api.Cell; +import builders.dsl.spreadsheet.api.Row; +import builders.dsl.spreadsheet.builder.api.SpreadsheetBuilder; +import builders.dsl.spreadsheet.builder.poi.PoiSpreadsheetBuilder; +import builders.dsl.spreadsheet.parser.data.json.JsonSpreadsheetParser; +import builders.dsl.spreadsheet.parser.data.yml.YmlSpreadsheetParser; +import builders.dsl.spreadsheet.query.api.SpreadsheetCriteria; +import builders.dsl.spreadsheet.query.api.SpreadsheetCriteriaResult; +import builders.dsl.spreadsheet.query.poi.PoiSpreadsheetCriteria; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +public final class SpreadsheetCli { + + private SpreadsheetCli() { + // utility class + } + + public static void main(String[] args) throws Exception { + run(args); + } + + static void run(String[] args) throws Exception { + if (args.length == 0 || "--help".equals(args[0]) || "-h".equals(args[0])) { + printHelp(); + return; + } + + switch (args[0]) { + case "create": + requireArgumentCount(args, 3, "create "); + create(Path.of(args[1]), new File(args[2])); + break; + case "query": + requireArgumentCount(args, 3, "query "); + query(new File(args[1]), Path.of(args[2])); + break; + default: + throw new IllegalArgumentException("Unknown command: " + args[0]); + } + } + + private static void printHelp() { + System.out.println("spreadsheet-builder-cli"); + System.out.println(); + System.out.println("Usage:"); + System.out.println(" create "); + System.out.println(" query "); + System.out.println(); + System.out.println("The create command accepts the data format supported by spreadsheet-builder-data."); + System.out.println("The query command accepts: sheet, where.column, and optional where.equals/where.contains."); + } + + private static void requireArgumentCount(String[] args, int count, String usage) { + if (args.length != count) { + throw new IllegalArgumentException("Usage: " + usage); + } + } + + private static void create(Path input, File output) throws IOException { + SpreadsheetBuilder builder = PoiSpreadsheetBuilder.create(output); + try (FileInputStream stream = new FileInputStream(input.toFile())) { + if (isJson(input)) { + new JsonSpreadsheetParser(builder).parse(stream); + } else { + new YmlSpreadsheetParser(builder).parse(stream); + } + } + System.out.println("created " + output.getPath()); + } + + private static void query(File workbookFile, Path queryFile) throws IOException { + Map query = readMap(queryFile); + String sheetName = string(query.getOrDefault("sheet", "Sheet1")); + Map where = map(query.get("where")); + int column = parseColumn(string(where.getOrDefault("column", "A"))); + Object expected = where.get("equals"); + String contains = where.containsKey("contains") ? string(where.get("contains")) : null; + + SpreadsheetCriteria criteria = PoiSpreadsheetCriteria.FACTORY.forFile(workbookFile); + SpreadsheetCriteriaResult result = criteria.query(workbook -> workbook.sheet(sheetName, sheet -> sheet.row(row -> row.cell(column)))); + List> matches = new ArrayList<>(); + + for (Row row : result.getRows()) { + Cell matchedCell = findCell(row.getCells(), column); + if (matchedCell == null || !matches(matchedCell.getValue(), expected, contains)) { + continue; + } + Map match = new LinkedHashMap<>(); + match.put("sheet", row.getSheet().getName()); + match.put("row", row.getNumber()); + match.put("matchedColumn", columnToName(column)); + match.put("matchedValue", matchedCell.getValue()); + match.put("values", row.getCells().stream().map(Cell::getValue).toList()); + matches.add(match); + } + + ObjectMapper json = new ObjectMapper(); + System.out.println(json.writerWithDefaultPrettyPrinter().writeValueAsString(Map.of("matches", matches))); + } + + private static Cell findCell(Collection cells, int column) { + for (Cell cell : cells) { + if (cell.getColumn() == column) { + return cell; + } + } + return null; + } + + private static boolean matches(Object actual, Object expected, String contains) { + if (expected != null) { + return Objects.equals(String.valueOf(actual), String.valueOf(expected)); + } + if (contains != null) { + return String.valueOf(actual).toLowerCase(Locale.ROOT).contains(contains.toLowerCase(Locale.ROOT)); + } + return true; + } + + private static Map readMap(Path input) throws IOException { + ObjectMapper mapper = isJson(input) ? new ObjectMapper() : new ObjectMapper(new YAMLFactory()); + return mapper.readValue(input.toFile(), new TypeReference>() { }); + } + + @SuppressWarnings("unchecked") + private static Map map(Object value) { + if (value instanceof Map) { + return (Map) value; + } + return Map.of(); + } + + private static String string(Object value) { + return value == null ? "" : String.valueOf(value); + } + + private static boolean isJson(Path path) { + return path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".json"); + } + + private static int parseColumn(String text) { + String column = text.trim().toUpperCase(Locale.ROOT); + if (column.matches("\\d+")) { + return Integer.parseInt(column); + } + + int result = 0; + for (int i = 0; i < column.length(); i++) { + char character = column.charAt(i); + if (character < 'A' || character > 'Z') { + throw new IllegalArgumentException("Invalid column: " + text); + } + result = result * 26 + character - 'A' + 1; + } + return result; + } + + private static String columnToName(int column) { + StringBuilder name = new StringBuilder(); + int current = column; + while (current > 0) { + current--; + name.insert(0, (char) ('A' + current % 26)); + current /= 26; + } + return name.toString(); + } +} diff --git a/libs/spreadsheet-builder-cli/src/test/groovy/builders/dsl/spreadsheet/cli/SpreadsheetCliSpec.groovy b/libs/spreadsheet-builder-cli/src/test/groovy/builders/dsl/spreadsheet/cli/SpreadsheetCliSpec.groovy new file mode 100644 index 0000000..ce7ac03 --- /dev/null +++ b/libs/spreadsheet-builder-cli/src/test/groovy/builders/dsl/spreadsheet/cli/SpreadsheetCliSpec.groovy @@ -0,0 +1,164 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * Copyright 2020-2026 Vladimir Orany. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package builders.dsl.spreadsheet.cli + +import builders.dsl.spreadsheet.query.poi.PoiSpreadsheetCriteria +import com.fasterxml.jackson.databind.ObjectMapper +import spock.lang.Specification +import spock.lang.TempDir + +class SpreadsheetCliSpec extends Specification { + + @TempDir File temporaryDirectory + + void 'writes an Excel workbook from YAML data'() { + given: + File yaml = new File(temporaryDirectory, 'people.yml') + yaml.text = '''\ +sheets: +- name: People + rows: + - cells: [Name, Age, City] + - cells: [Alice, 30, Prague] + - cells: [Bob, 41, Brno] +''' + File workbook = new File(temporaryDirectory, 'people.xlsx') + + when: + SpreadsheetCli.run('create', yaml.absolutePath, workbook.absolutePath) + + then: + workbook.file + PoiSpreadsheetCriteria.FACTORY.forFile(workbook).query { book -> + book.sheet('People') { sheet -> + sheet.row(2) { row -> + row.cell('A') { cell -> cell.string('Alice') } + } + } + }.cell + } + + void 'writes an Excel workbook from JSON data'() { + given: + File json = new File(temporaryDirectory, 'people.json') + json.text = '''\ +{ + "sheets": [ + { + "name": "People", + "rows": [ + {"cells": ["Name", "Age", "City"]}, + {"cells": ["Alice", 30, "Prague"]}, + {"cells": ["Bob", 41, "Brno"]} + ] + } + ] +} +''' + File workbook = new File(temporaryDirectory, 'people-json.xlsx') + + when: + SpreadsheetCli.run('create', json.absolutePath, workbook.absolutePath) + + then: + workbook.file + PoiSpreadsheetCriteria.FACTORY.forFile(workbook).query { book -> + book.sheet('People') { sheet -> + sheet.row(3) { row -> + row.cell('C') { cell -> cell.string('Brno') } + } + } + }.cell + } + + void 'queries an Excel workbook using YAML query criteria'() { + given: + File workbook = workbook() + File query = new File(temporaryDirectory, 'query.yml') + query.text = '''\ +sheet: People +where: + column: C + equals: Prague +''' + + when: + String output = captureStandardOutput { + SpreadsheetCli.run('query', workbook.absolutePath, query.absolutePath) + } + Map result = new ObjectMapper().readValue(output, Map) + + then: + result.matches*.row == [2, 4] + result.matches[0].values == ['Alice', 30.0, 'Prague'] + } + + void 'queries an Excel workbook using JSON query criteria'() { + given: + File workbook = workbook() + File query = new File(temporaryDirectory, 'query.json') + query.text = '''\ +{ + "sheet": "People", + "where": { + "column": "A", + "contains": "bo" + } +} +''' + + when: + String output = captureStandardOutput { + SpreadsheetCli.run('query', workbook.absolutePath, query.absolutePath) + } + Map result = new ObjectMapper().readValue(output, Map) + + then: + result.matches*.row == [3] + result.matches[0].values == ['Bob', 41.0, 'Brno'] + } + + private static String captureStandardOutput(Closure action) { + PrintStream original = System.out + ByteArrayOutputStream buffer = new ByteArrayOutputStream() + System.out = new PrintStream(buffer, true, 'UTF-8') + try { + action.call() + } finally { + System.out = original + } + return buffer.toString('UTF-8') + } + + private File workbook() { + File yaml = new File(temporaryDirectory, "people-${System.nanoTime()}.yml") + yaml.text = '''\ +sheets: +- name: People + rows: + - cells: [Name, Age, City] + - cells: [Alice, 30, Prague] + - cells: [Bob, 41, Brno] + - cells: [Carol, 29, Prague] +''' + File workbook = new File(temporaryDirectory, "people-${System.nanoTime()}.xlsx") + SpreadsheetCli.run('create', yaml.absolutePath, workbook.absolutePath) + return workbook + } + +} From 1ce52949a7527c79621f37401682a40daff8dc35 Mon Sep 17 00:00:00 2001 From: jean_de_bot Date: Thu, 11 Jun 2026 14:45:59 +0000 Subject: [PATCH 2/6] fix: acknowledge CLI stdout output --- .../java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java b/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java index f322656..bf9c3ec 100644 --- a/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java +++ b/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java @@ -42,17 +42,18 @@ import java.util.Map; import java.util.Objects; +@SuppressWarnings("java:S106") public final class SpreadsheetCli { private SpreadsheetCli() { // utility class } - public static void main(String[] args) throws Exception { + public static void main(String[] args) throws IOException { run(args); } - static void run(String[] args) throws Exception { + static void run(String[] args) throws IOException { if (args.length == 0 || "--help".equals(args[0]) || "-h".equals(args[0])) { printHelp(); return; From 4ed1c7cc80d6c7974f4c84e420b6fb2d1f2d6b09 Mon Sep 17 00:00:00 2001 From: jean_de_bot Date: Thu, 11 Jun 2026 15:03:53 +0000 Subject: [PATCH 3/6] fix: use serialized criteria for CLI queries --- README.adoc | 20 +- .../dsl/spreadsheet/cli/SpreadsheetCli.java | 273 ++++++++++++++---- .../spreadsheet/cli/SpreadsheetCliSpec.groovy | 45 ++- 3 files changed, 259 insertions(+), 79 deletions(-) diff --git a/README.adoc b/README.adoc index c4ff018..59b02c5 100644 --- a/README.adoc +++ b/README.adoc @@ -19,15 +19,29 @@ Create an Excel workbook from JSON/YAML data: spreadsheet-builder-cli create people.yml people.xlsx ---- -Query an Excel workbook with JSON/YAML criteria: +Query an Excel workbook with serialized JSON/YAML criteria: [source,bash] ---- spreadsheet-builder-cli query people.xlsx query.yml ---- -A query currently supports `sheet`, `where.column`, and optional -`where.equals` or `where.contains`, returning matching rows as JSON. +The query file mirrors the criteria DSL tree, so it can express workbook, +sheet, row, cell, page, and style criteria instead of a one-off filter shape: + +[source,yaml] +---- +sheets: +- name: People + rows: + - from: 2 + to: 10 + cells: + - column: C + value: Prague +---- + +The query command returns matching `sheets`, `rows`, and `cells` as JSON. == Acknowledgement This project is inspired by http://www.craigburke.com/document-builder/[Groovy Document Builder] diff --git a/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java b/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java index bf9c3ec..ee8111f 100644 --- a/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java +++ b/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java @@ -18,13 +18,21 @@ package builders.dsl.spreadsheet.cli; import builders.dsl.spreadsheet.api.Cell; +import builders.dsl.spreadsheet.api.ForegroundFill; +import builders.dsl.spreadsheet.api.Keywords; import builders.dsl.spreadsheet.api.Row; +import builders.dsl.spreadsheet.api.Sheet; import builders.dsl.spreadsheet.builder.api.SpreadsheetBuilder; import builders.dsl.spreadsheet.builder.poi.PoiSpreadsheetBuilder; import builders.dsl.spreadsheet.parser.data.json.JsonSpreadsheetParser; import builders.dsl.spreadsheet.parser.data.yml.YmlSpreadsheetParser; +import builders.dsl.spreadsheet.query.api.CellCriterion; +import builders.dsl.spreadsheet.query.api.CellStyleCriterion; +import builders.dsl.spreadsheet.query.api.RowCriterion; +import builders.dsl.spreadsheet.query.api.SheetCriterion; import builders.dsl.spreadsheet.query.api.SpreadsheetCriteria; import builders.dsl.spreadsheet.query.api.SpreadsheetCriteriaResult; +import builders.dsl.spreadsheet.query.api.WorkbookCriterion; import builders.dsl.spreadsheet.query.poi.PoiSpreadsheetCriteria; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -34,15 +42,18 @@ import java.io.FileInputStream; import java.io.IOException; import java.nio.file.Path; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.ArrayList; import java.util.Collection; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Objects; +import java.util.function.Consumer; -@SuppressWarnings("java:S106") +@SuppressWarnings({"java:S106", "java:S3776"}) public final class SpreadsheetCli { private SpreadsheetCli() { @@ -78,10 +89,10 @@ private static void printHelp() { System.out.println(); System.out.println("Usage:"); System.out.println(" create "); - System.out.println(" query "); + System.out.println(" query "); System.out.println(); System.out.println("The create command accepts the data format supported by spreadsheet-builder-data."); - System.out.println("The query command accepts: sheet, where.column, and optional where.equals/where.contains."); + System.out.println("The query command accepts a serialized criteria tree: sheets, rows, cells, page, and or."); } private static void requireArgumentCount(String[] args, int count, String usage) { @@ -103,52 +114,191 @@ private static void create(Path input, File output) throws IOException { } private static void query(File workbookFile, Path queryFile) throws IOException { - Map query = readMap(queryFile); - String sheetName = string(query.getOrDefault("sheet", "Sheet1")); - Map where = map(query.get("where")); - int column = parseColumn(string(where.getOrDefault("column", "A"))); - Object expected = where.get("equals"); - String contains = where.containsKey("contains") ? string(where.get("contains")) : null; - + Map serializedCriteria = readMap(queryFile); SpreadsheetCriteria criteria = PoiSpreadsheetCriteria.FACTORY.forFile(workbookFile); - SpreadsheetCriteriaResult result = criteria.query(workbook -> workbook.sheet(sheetName, sheet -> sheet.row(row -> row.cell(column)))); - List> matches = new ArrayList<>(); + SpreadsheetCriteriaResult result = criteria.query(workbook -> applyWorkbook(workbook, serializedCriteria)); + ObjectMapper json = new ObjectMapper(); + System.out.println(json.writerWithDefaultPrettyPrinter().writeValueAsString(resultMap(result))); + } - for (Row row : result.getRows()) { - Cell matchedCell = findCell(row.getCells(), column); - if (matchedCell == null || !matches(matchedCell.getValue(), expected, contains)) { - continue; + private static void applyWorkbook(WorkbookCriterion workbook, Map spec) { + for (Object sheetValue : list(spec.get("sheets"))) { + Map sheet = map(sheetValue); + if (sheet.containsKey("name")) { + workbook.sheet(string(sheet.get("name")), criterion -> applySheet(criterion, sheet)); + } else { + workbook.sheet(criterion -> applySheet(criterion, sheet)); } - Map match = new LinkedHashMap<>(); - match.put("sheet", row.getSheet().getName()); - match.put("row", row.getNumber()); - match.put("matchedColumn", columnToName(column)); - match.put("matchedValue", matchedCell.getValue()); - match.put("values", row.getCells().stream().map(Cell::getValue).toList()); - matches.add(match); } + for (Object alternative : list(spec.get("or"))) { + workbook.or((Consumer) criterion -> applyWorkbook(criterion, map(alternative))); + } + if (!spec.containsKey("sheets") && spec.containsKey("sheet")) { + workbook.sheet(string(spec.get("sheet")), criterion -> applySheet(criterion, spec)); + } + } - ObjectMapper json = new ObjectMapper(); - System.out.println(json.writerWithDefaultPrettyPrinter().writeValueAsString(Map.of("matches", matches))); + private static void applySheet(SheetCriterion sheet, Map spec) { + if (spec.containsKey("state")) { + sheet.state(enumValue(Keywords.SheetState.class, spec.get("state"))); + } + if (spec.containsKey("page")) { + applyPage(sheet, map(spec.get("page"))); + } + for (Object rowValue : list(spec.get("rows"))) { + applyRowSelection(sheet, map(rowValue)); + } + for (Object alternative : list(spec.get("or"))) { + sheet.or((Consumer) criterion -> applySheet(criterion, map(alternative))); + } } - private static Cell findCell(Collection cells, int column) { - for (Cell cell : cells) { - if (cell.getColumn() == column) { - return cell; + private static void applyPage(SheetCriterion sheet, Map spec) { + sheet.page(page -> { + if (spec.containsKey("orientation")) { + page.orientation(enumValue(Keywords.Orientation.class, spec.get("orientation"))); + } + if (spec.containsKey("paper")) { + page.paper(enumValue(Keywords.Paper.class, spec.get("paper"))); } + }); + } + + private static void applyRowSelection(SheetCriterion sheet, Map spec) { + if (spec.containsKey("from") && spec.containsKey("to")) { + sheet.row(integer(spec.get("from")), integer(spec.get("to"))); + sheet.row(row -> applyRow(row, spec)); + } else if (spec.containsKey("number")) { + sheet.row(integer(spec.get("number")), row -> applyRow(row, spec)); + } else if (spec.containsKey("row")) { + sheet.row(integer(spec.get("row")), row -> applyRow(row, spec)); + } else { + sheet.row(row -> applyRow(row, spec)); + } + } + + private static void applyRow(RowCriterion row, Map spec) { + for (Object cellValue : list(spec.get("cells"))) { + applyCellSelection(row, map(cellValue)); + } + for (Object alternative : list(spec.get("or"))) { + row.or((Consumer) criterion -> applyRow(criterion, map(alternative))); + } + } + + private static void applyCellSelection(RowCriterion row, Map spec) { + if (spec.containsKey("from") && spec.containsKey("to")) { + Object from = spec.get("from"); + Object to = spec.get("to"); + if (from instanceof Number && to instanceof Number) { + row.cell(integer(from), integer(to), cell -> applyCell(cell, spec)); + } else { + row.cell(string(from), string(to), cell -> applyCell(cell, spec)); + } + } else if (spec.containsKey("column")) { + Object column = spec.get("column"); + if (column instanceof Number) { + row.cell(integer(column), cell -> applyCell(cell, spec)); + } else { + row.cell(string(column), cell -> applyCell(cell, spec)); + } + } else { + row.cell(cell -> applyCell(cell, spec)); + } + } + + private static void applyCell(CellCriterion cell, Map spec) { + if (spec.containsKey("value")) { + cell.value(spec.get("value")); + } + if (spec.containsKey("string")) { + cell.string(string(spec.get("string"))); + } + if (spec.containsKey("number")) { + cell.number(decimal(spec.get("number"))); + } + if (spec.containsKey("bool")) { + cell.bool(Boolean.valueOf(string(spec.get("bool")))); + } + if (spec.containsKey("localDate")) { + cell.localDate(LocalDate.parse(string(spec.get("localDate")))); + } + if (spec.containsKey("localDateTime")) { + cell.localDateTime(LocalDateTime.parse(string(spec.get("localDateTime")))); + } + if (spec.containsKey("localTime")) { + cell.localTime(LocalTime.parse(string(spec.get("localTime")))); + } + if (spec.containsKey("rowspan")) { + cell.rowspan(integer(spec.get("rowspan"))); + } + if (spec.containsKey("colspan")) { + cell.colspan(integer(spec.get("colspan"))); + } + if (spec.containsKey("name")) { + cell.name(string(spec.get("name"))); + } + if (spec.containsKey("comment")) { + cell.comment(string(spec.get("comment"))); + } + if (spec.containsKey("style")) { + cell.style(style -> applyStyle(style, map(spec.get("style")))); + } + for (Object alternative : list(spec.get("or"))) { + cell.or((Consumer) criterion -> applyCell(criterion, map(alternative))); } - return null; } - private static boolean matches(Object actual, Object expected, String contains) { - if (expected != null) { - return Objects.equals(String.valueOf(actual), String.valueOf(expected)); + private static void applyStyle(CellStyleCriterion style, Map spec) { + if (spec.containsKey("background")) { + style.background(string(spec.get("background"))); + } + if (spec.containsKey("foreground")) { + style.foreground(string(spec.get("foreground"))); } - if (contains != null) { - return String.valueOf(actual).toLowerCase(Locale.ROOT).contains(contains.toLowerCase(Locale.ROOT)); + if (spec.containsKey("fill")) { + style.fill(enumValue(ForegroundFill.class, spec.get("fill"))); } - return true; + if (spec.containsKey("indent")) { + style.indent(integer(spec.get("indent"))); + } + if (spec.containsKey("rotation")) { + style.rotation(integer(spec.get("rotation"))); + } + if (spec.containsKey("format")) { + style.format(string(spec.get("format"))); + } + } + + private static Map resultMap(SpreadsheetCriteriaResult result) { + Map output = new LinkedHashMap<>(); + output.put("sheets", result.getSheets().stream().map(SpreadsheetCli::sheetMap).toList()); + output.put("rows", result.getRows().stream().map(SpreadsheetCli::rowMap).toList()); + output.put("cells", result.getCells().stream().map(SpreadsheetCli::cellMap).toList()); + return output; + } + + private static Map sheetMap(Sheet sheet) { + Map map = new LinkedHashMap<>(); + map.put("name", sheet.getName()); + return map; + } + + private static Map rowMap(Row row) { + Map map = new LinkedHashMap<>(); + map.put("sheet", row.getSheet().getName()); + map.put("row", row.getNumber()); + map.put("values", row.getCells().stream().map(Cell::getValue).toList()); + return map; + } + + private static Map cellMap(Cell cell) { + Map map = new LinkedHashMap<>(); + map.put("sheet", cell.getRow().getSheet().getName()); + map.put("row", cell.getRow().getNumber()); + map.put("column", cell.getColumnAsString()); + map.put("value", cell.getValue()); + return map; } private static Map readMap(Path input) throws IOException { @@ -164,39 +314,40 @@ private static Map map(Object value) { return Map.of(); } + private static List list(Object value) { + if (value instanceof Collection) { + return new ArrayList<>((Collection) value); + } + if (value == null) { + return List.of(); + } + return List.of(value); + } + private static String string(Object value) { return value == null ? "" : String.valueOf(value); } - private static boolean isJson(Path path) { - return path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".json"); + private static Integer integer(Object value) { + if (value instanceof Number) { + return ((Number) value).intValue(); + } + return Integer.valueOf(string(value)); } - private static int parseColumn(String text) { - String column = text.trim().toUpperCase(Locale.ROOT); - if (column.matches("\\d+")) { - return Integer.parseInt(column); + private static Double decimal(Object value) { + if (value instanceof Number) { + return ((Number) value).doubleValue(); } + return Double.valueOf(string(value)); + } - int result = 0; - for (int i = 0; i < column.length(); i++) { - char character = column.charAt(i); - if (character < 'A' || character > 'Z') { - throw new IllegalArgumentException("Invalid column: " + text); - } - result = result * 26 + character - 'A' + 1; - } - return result; + private static > T enumValue(Class type, Object value) { + String name = string(value).trim().replace('-', '_').toUpperCase(Locale.ROOT); + return Enum.valueOf(type, name); } - private static String columnToName(int column) { - StringBuilder name = new StringBuilder(); - int current = column; - while (current > 0) { - current--; - name.insert(0, (char) ('A' + current % 26)); - current /= 26; - } - return name.toString(); + private static boolean isJson(Path path) { + return path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".json"); } } diff --git a/libs/spreadsheet-builder-cli/src/test/groovy/builders/dsl/spreadsheet/cli/SpreadsheetCliSpec.groovy b/libs/spreadsheet-builder-cli/src/test/groovy/builders/dsl/spreadsheet/cli/SpreadsheetCliSpec.groovy index ce7ac03..3a36b07 100644 --- a/libs/spreadsheet-builder-cli/src/test/groovy/builders/dsl/spreadsheet/cli/SpreadsheetCliSpec.groovy +++ b/libs/spreadsheet-builder-cli/src/test/groovy/builders/dsl/spreadsheet/cli/SpreadsheetCliSpec.groovy @@ -86,15 +86,19 @@ sheets: }.cell } - void 'queries an Excel workbook using YAML query criteria'() { + void 'queries an Excel workbook using serialized YAML criteria'() { given: File workbook = workbook() File query = new File(temporaryDirectory, 'query.yml') query.text = '''\ -sheet: People -where: - column: C - equals: Prague +sheets: +- name: People + rows: + - from: 2 + to: 4 + cells: + - column: C + value: Prague ''' when: @@ -104,21 +108,32 @@ where: Map result = new ObjectMapper().readValue(output, Map) then: - result.matches*.row == [2, 4] - result.matches[0].values == ['Alice', 30.0, 'Prague'] + result.cells*.row == [2, 4] + result.cells*.column == ['C', 'C'] + result.cells*.value == ['Prague', 'Prague'] + result.rows[0].values == ['Alice', 30.0, 'Prague'] } - void 'queries an Excel workbook using JSON query criteria'() { + void 'queries an Excel workbook using serialized JSON criteria'() { given: File workbook = workbook() File query = new File(temporaryDirectory, 'query.json') query.text = '''\ { - "sheet": "People", - "where": { - "column": "A", - "contains": "bo" - } + "sheets": [ + { + "name": "People", + "rows": [ + {"cells": [{"column": "A", "string": "Bob"}]} + ] + }, + { + "name": "People", + "rows": [ + {"cells": [{"column": "A", "string": "Carol"}]} + ] + } + ] } ''' @@ -129,8 +144,8 @@ where: Map result = new ObjectMapper().readValue(output, Map) then: - result.matches*.row == [3] - result.matches[0].values == ['Bob', 41.0, 'Brno'] + result.cells*.value == ['Bob', 'Carol'] + result.rows*.row == [3, 4] } private static String captureStandardOutput(Closure action) { From d2436f847e5fb6225a4a0a30d18edacf48832ee3 Mon Sep 17 00:00:00 2001 From: jean_de_bot Date: Thu, 11 Jun 2026 15:08:38 +0000 Subject: [PATCH 4/6] fix: avoid duplicated CLI query keys --- .../dsl/spreadsheet/cli/SpreadsheetCli.java | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java b/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java index ee8111f..554aa11 100644 --- a/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java +++ b/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java @@ -56,6 +56,13 @@ @SuppressWarnings({"java:S106", "java:S3776"}) public final class SpreadsheetCli { + private static final String SHEET = "sheet"; + private static final String SHEETS = "sheets"; + private static final String ROW = "row"; + private static final String NUMBER = "number"; + private static final String COLUMN = "column"; + private static final String VALUE = "value"; + private SpreadsheetCli() { // utility class } @@ -122,7 +129,7 @@ private static void query(File workbookFile, Path queryFile) throws IOException } private static void applyWorkbook(WorkbookCriterion workbook, Map spec) { - for (Object sheetValue : list(spec.get("sheets"))) { + for (Object sheetValue : list(spec.get(SHEETS))) { Map sheet = map(sheetValue); if (sheet.containsKey("name")) { workbook.sheet(string(sheet.get("name")), criterion -> applySheet(criterion, sheet)); @@ -133,8 +140,8 @@ private static void applyWorkbook(WorkbookCriterion workbook, Map) criterion -> applyWorkbook(criterion, map(alternative))); } - if (!spec.containsKey("sheets") && spec.containsKey("sheet")) { - workbook.sheet(string(spec.get("sheet")), criterion -> applySheet(criterion, spec)); + if (!spec.containsKey(SHEETS) && spec.containsKey(SHEET)) { + workbook.sheet(string(spec.get(SHEET)), criterion -> applySheet(criterion, spec)); } } @@ -168,10 +175,10 @@ private static void applyRowSelection(SheetCriterion sheet, Map if (spec.containsKey("from") && spec.containsKey("to")) { sheet.row(integer(spec.get("from")), integer(spec.get("to"))); sheet.row(row -> applyRow(row, spec)); - } else if (spec.containsKey("number")) { - sheet.row(integer(spec.get("number")), row -> applyRow(row, spec)); - } else if (spec.containsKey("row")) { - sheet.row(integer(spec.get("row")), row -> applyRow(row, spec)); + } else if (spec.containsKey(NUMBER)) { + sheet.row(integer(spec.get(NUMBER)), row -> applyRow(row, spec)); + } else if (spec.containsKey(ROW)) { + sheet.row(integer(spec.get(ROW)), row -> applyRow(row, spec)); } else { sheet.row(row -> applyRow(row, spec)); } @@ -195,8 +202,8 @@ private static void applyCellSelection(RowCriterion row, Map spe } else { row.cell(string(from), string(to), cell -> applyCell(cell, spec)); } - } else if (spec.containsKey("column")) { - Object column = spec.get("column"); + } else if (spec.containsKey(COLUMN)) { + Object column = spec.get(COLUMN); if (column instanceof Number) { row.cell(integer(column), cell -> applyCell(cell, spec)); } else { @@ -208,8 +215,8 @@ private static void applyCellSelection(RowCriterion row, Map spe } private static void applyCell(CellCriterion cell, Map spec) { - if (spec.containsKey("value")) { - cell.value(spec.get("value")); + if (spec.containsKey(VALUE)) { + cell.value(spec.get(VALUE)); } if (spec.containsKey("string")) { cell.string(string(spec.get("string"))); @@ -286,18 +293,18 @@ private static Map sheetMap(Sheet sheet) { private static Map rowMap(Row row) { Map map = new LinkedHashMap<>(); - map.put("sheet", row.getSheet().getName()); - map.put("row", row.getNumber()); + map.put(SHEET, row.getSheet().getName()); + map.put(ROW, row.getNumber()); map.put("values", row.getCells().stream().map(Cell::getValue).toList()); return map; } private static Map cellMap(Cell cell) { Map map = new LinkedHashMap<>(); - map.put("sheet", cell.getRow().getSheet().getName()); - map.put("row", cell.getRow().getNumber()); - map.put("column", cell.getColumnAsString()); - map.put("value", cell.getValue()); + map.put(SHEET, cell.getRow().getSheet().getName()); + map.put(ROW, cell.getRow().getNumber()); + map.put(COLUMN, cell.getColumnAsString()); + map.put(VALUE, cell.getValue()); return map; } From 38d39028fdc6163e6d1ed2de2f7023fa5c70dbdd Mon Sep 17 00:00:00 2001 From: jean_de_bot Date: Thu, 11 Jun 2026 15:16:50 +0000 Subject: [PATCH 5/6] fix: reuse serialized query constants --- .../java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java b/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java index 554aa11..52c7db0 100644 --- a/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java +++ b/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java @@ -221,8 +221,8 @@ private static void applyCell(CellCriterion cell, Map spec) { if (spec.containsKey("string")) { cell.string(string(spec.get("string"))); } - if (spec.containsKey("number")) { - cell.number(decimal(spec.get("number"))); + if (spec.containsKey(NUMBER)) { + cell.number(decimal(spec.get(NUMBER))); } if (spec.containsKey("bool")) { cell.bool(Boolean.valueOf(string(spec.get("bool")))); @@ -279,7 +279,7 @@ private static void applyStyle(CellStyleCriterion style, Map spe private static Map resultMap(SpreadsheetCriteriaResult result) { Map output = new LinkedHashMap<>(); - output.put("sheets", result.getSheets().stream().map(SpreadsheetCli::sheetMap).toList()); + output.put(SHEETS, result.getSheets().stream().map(SpreadsheetCli::sheetMap).toList()); output.put("rows", result.getRows().stream().map(SpreadsheetCli::rowMap).toList()); output.put("cells", result.getCells().stream().map(SpreadsheetCli::cellMap).toList()); return output; From c20afcd4dd226dd7d71b4f29a23f8fdcc50b3a4a Mon Sep 17 00:00:00 2001 From: jean_de_bot Date: Thu, 11 Jun 2026 15:23:02 +0000 Subject: [PATCH 6/6] fix: suppress literal warnings for serialized keys --- .../main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java b/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java index 52c7db0..900427b 100644 --- a/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java +++ b/libs/spreadsheet-builder-cli/src/main/java/builders/dsl/spreadsheet/cli/SpreadsheetCli.java @@ -53,7 +53,7 @@ import java.util.Map; import java.util.function.Consumer; -@SuppressWarnings({"java:S106", "java:S3776"}) +@SuppressWarnings({"java:S106", "java:S1192", "java:S3776"}) public final class SpreadsheetCli { private static final String SHEET = "sheet";