diff --git a/core/src/main/java/dev/suprim/query/model/DbTable.java b/core/src/main/java/dev/suprim/query/model/DbTable.java index 74f7560..bceda83 100644 --- a/core/src/main/java/dev/suprim/query/model/DbTable.java +++ b/core/src/main/java/dev/suprim/query/model/DbTable.java @@ -2,6 +2,7 @@ import dev.suprim.query.exception.DbErrorCode; import dev.suprim.query.exception.DbException; +import lombok.Builder; import lombok.extern.slf4j.Slf4j; import java.util.List; @@ -9,115 +10,256 @@ import static java.util.Objects.isNull; @Slf4j +@Builder public record DbTable( - String schema, - String name, - String fullName, - String alias, - List dbColumns, - String type, - String coverChar + String schema, String name, String fullName, String alias, + List dbColumns, String type, String coverChar ) { - public String render() { - return fullName + " " + alias; - } - - public DbTable copyWithAlias(String tableAlias) { - List columns = dbColumns.stream() - .map(col -> col.copyWithTableAlias(tableAlias)) - .toList(); - return new DbTable(schema, name, fullName, tableAlias, columns, type, coverChar); - } - - public DbColumn buildColumn(String columnName) throws DbException { - if (isNull(columnName) || columnName.isBlank()) { - throw new DbException(DbErrorCode.INVALID_REQUEST, - "Column name must not be null or blank"); - } - DbAlias dbAlias = getAlias(columnName); - return getDbColumn(dbAlias); - } - - private DbColumn getDbColumn(DbAlias dbAlias) throws DbException { - return this.dbColumns.stream() - .filter(col -> dbAlias.name().equalsIgnoreCase(col.name())) - .map(col -> col.copyWithAlias(dbAlias)) - .findFirst() - .orElseThrow(() -> new DbException( - DbErrorCode.INVALID_REQUEST, - "Column not found: %s.%s".formatted(name, dbAlias.name()) - )); - } - - private DbAlias getAlias(String name) { - String[] aliasParts = name.split(":"); - String columnName = aliasParts[0]; - String colName = columnName; - String jsonParts = ""; - - String[][] patterns = { - {"->>", "->>"}, - {"->", "->"}, - {"#>>", "#>>", "."}, - {"#>", "#>", "."}, - {"**", "->>", "'"}, - {"*", "->", "'"} - }; - - for (String[] patternInfo : patterns) { - String pattern = patternInfo[0]; - if (columnName.contains(pattern)) { - int index = columnName.indexOf(pattern); - colName = columnName.substring(0, index); - String rawPart = columnName.substring(index + pattern.length()); - String prefix = patternInfo[1]; - String wrapper = patternInfo.length == 3 ? patternInfo[2] : ""; - String finalValue = rawPart; - - if (".".equals(wrapper)) { - finalValue = rawPart.replace(".", ","); - jsonParts = prefix + finalValue; - } else if (!wrapper.isEmpty()) { - jsonParts = prefix + wrapper + finalValue + wrapper; - } else { - jsonParts = prefix + finalValue; - } - break; - } - } - - String alias = aliasParts.length == 2 ? aliasParts[1] : ""; - return new DbAlias(colName.trim(), alias.trim(), jsonParts); - } - - public List buildColumns() { - return dbColumns; - } - - public List buildPkColumns() { - return dbColumns.stream() - .filter(DbColumn::pk) - .toList(); - } - - public String[] getKeyColumnNames() { - return buildPkColumns().stream() - .map(DbColumn::name) - .toList() - .toArray(String[]::new); - } - - public String getColumnDataTypeName(String columnName) throws DbException { - return lookupColumn(columnName).columnDataTypeName(); - } - - private DbColumn lookupColumn(String columnName) throws DbException { - return dbColumns.stream() - .filter(col -> columnName.equalsIgnoreCase(col.name())) - .findFirst() - .orElseThrow(() -> new DbException( - DbErrorCode.INVALID_REQUEST, - "Column not found: %s.%s".formatted(name, columnName) - )); - } + public String render() { + return fullName + " " + alias; + } + + public DbTable copyWithAlias(String tableAlias) { + List columns = + dbColumns.stream() + .map(col -> col.copyWithTableAlias(tableAlias)) + .toList(); + return DbTable.builder() + .schema(schema) + .name(name) + .fullName(fullName) + .alias(tableAlias) + .dbColumns(columns) + .type(type) + .coverChar(coverChar) + .build(); + } + + public DbColumn buildColumn(String columnName) throws DbException { + if (isNull(columnName) || columnName.isBlank()) { + throw new DbException( + DbErrorCode.INVALID_REQUEST, + "Column name must not be null or blank" + ); + } + DbAlias dbAlias = getAlias(columnName); + return getDbColumn(dbAlias); + } + + private DbColumn getDbColumn(DbAlias dbAlias) throws DbException { + return this.dbColumns.stream() + .filter(col -> dbAlias.name() + .equalsIgnoreCase(col.name()) + ) + .map(col -> col.copyWithAlias(dbAlias)) + .findFirst() + .orElseThrow(() -> new DbException( + DbErrorCode.INVALID_REQUEST, + "Column not found: %s.%s".formatted( + name, + dbAlias.name() + ) + )); + } + + /** + * Parses a field expression into column name, alias, and JSONB operator parts. + * Uses {@code column:alias} syntax for renaming output columns. + * + *

Syntax guide: + *

    + *
  • Arrow operators ({@code ->}, {@code ->>}): chainable, auto-quotes unquoted keys
  • + *
  • Path operators ({@code #>}, {@code #>>}): dot-separated path, wrapped in PostgreSQL array syntax
  • + *
  • Asterisk shorthand ({@code *}, {@code **}): splits on delimiter, quotes each segment
  • + *
+ * + * @see DbAlias + */ + private DbAlias getAlias(String fieldExpression) throws DbException { + String[] aliasParts = fieldExpression.split(":", 2); + String columnName = aliasParts[0]; + String colName = columnName; + String jsonParts = ""; + + if (columnName.contains("#>>")) { + colName = columnName.substring(0, columnName.indexOf("#>>")); + jsonParts = parsePathArray(columnName, "#>>", 3); + } else if (columnName.contains("#>")) { + colName = columnName.substring(0, columnName.indexOf("#>")); + jsonParts = parsePathArray(columnName, "#>", 2); + } else if (columnName.contains("->>") || columnName.contains("->")) { + jsonParts = parseChainedArrows(columnName); + colName = columnName.substring(0, columnName.indexOf("->")); + } else if (columnName.contains("**")) { + colName = columnName.substring(0, columnName.indexOf("**")); + jsonParts = parseAsteriskShorthand( + columnName, + "\\*\\*", + true, + fieldExpression + ); + } else if (columnName.contains("*")) { + colName = columnName.substring(0, columnName.indexOf("*")); + jsonParts = parseAsteriskShorthand( + columnName, + "\\*", + false, + fieldExpression + ); + } + + String alias = aliasParts.length == 2 ? aliasParts[1] : ""; + return new DbAlias(colName.trim(), alias.trim(), jsonParts); + } + + /** + * Parses PostgreSQL path array syntax: {@code data#>>a.b.c} produces {@code #>>'{a,b,c}'} + */ + private String parsePathArray( + String columnName, + String operator, + int operatorLength + ) { + int index = columnName.indexOf(operator); + String path = columnName.substring(index + operatorLength); + return operator + "'{" + path.replace(".", ",") + "}'"; + } + + /** + * Parses asterisk shorthand into chained arrow operators. + *
    + *
  • {@code **} (textExtractLast=true): intermediate uses {@code ->}, last uses {@code ->>}
  • + *
  • {@code *} (textExtractLast=false): all segments use {@code ->}
  • + *
+ */ + private String parseAsteriskShorthand( + String columnName, String delimiterRegex, + boolean textExtractLast, String fieldExpression + ) throws DbException { + int index = textExtractLast ? columnName.indexOf("**") : columnName.indexOf( + "*"); + String remainder = columnName.substring( + index + (textExtractLast ? 2 : 1)); + validateNotBlank(remainder, fieldExpression); + String[] segments = remainder.split(delimiterRegex); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < segments.length; i++) { + if (textExtractLast && i == segments.length - 1) { + sb.append("->>'").append(segments[i]).append("'"); + } else { + sb.append("->'").append(segments[i]).append("'"); + } + } + return sb.toString(); + } + + private void validateNotBlank( + String remainder, + String expression + ) throws DbException { + if (remainder.isBlank()) { + throw new DbException( + DbErrorCode.INVALID_REQUEST, + "Invalid JSONB path expression: trailing delimiter in '%s'".formatted( + expression) + ); + } + } + + /** + * Parses chained arrow operators ({@code ->} and {@code ->>}) from a field expression. + * Walks the expression left-to-right, consuming each operator and its key. + * Unquoted keys are auto-wrapped in single quotes for valid PostgreSQL syntax. + * + *

Example: {@code data->feedback->>type} produces {@code ->'feedback'->>'type'} + */ + private String parseChainedArrows(String expression) throws DbException { + StringBuilder result = new StringBuilder(); + int i = expression.indexOf("->"); + + while (i < expression.length() && expression.charAt(i) == '-') { + if (expression.startsWith("->>", i)) { + i += 3; + String key = extractKey(expression, i); + i += key.length(); + result.append("->>").append(quoteIfNeeded(key)); + } else { + i += 2; + String key = extractKey(expression, i); + i += key.length(); + result.append("->").append(quoteIfNeeded(key)); + } + } + return result.toString(); + } + + /** + * Extracts a JSON key starting at the given position. + *

    + *
  • Quoted: {@code 'feedback'} — reads until closing quote
  • + *
  • Unquoted: {@code feedback} — reads until next {@code ->} or end of string
  • + *
+ */ + private String extractKey(String expression, int start) throws DbException { + if (start >= expression.length()) { + return ""; + } + if (expression.charAt(start) == '\'') { + int end = expression.indexOf('\'', start + 1); + if (end == -1) { + throw new DbException( + DbErrorCode.INVALID_REQUEST, + "Unclosed quote in JSONB path expression: '%s'".formatted( + expression) + ); + } + return expression.substring(start, end + 1); + } + int nextOp = expression.indexOf("->", start); + if (nextOp == -1) { + return expression.substring(start); + } + return expression.substring(start, nextOp); + } + + /** + * Wraps key in single quotes if not already quoted. + * A key is considered quoted if it starts with a single quote and has length >= 2. + */ + private String quoteIfNeeded(String key) { + if (key.length() >= 2 && key.charAt(0) == '\'') { + return key; + } + return "'" + key + "'"; + } + + public List buildColumns() { + return dbColumns; + } + + public List buildPkColumns() { + return dbColumns.stream().filter(DbColumn::pk).toList(); + } + + public String[] getKeyColumnNames() { + return buildPkColumns().stream().map(DbColumn::name).toList().toArray( + String[]::new); + } + + public String getColumnDataTypeName(String columnName) throws DbException { + return lookupColumn(columnName).columnDataTypeName(); + } + + private DbColumn lookupColumn(String columnName) throws DbException { + return dbColumns.stream() + .filter(col -> columnName.equalsIgnoreCase(col.name())) + .findFirst() + .orElseThrow(() -> new DbException( + DbErrorCode.INVALID_REQUEST, + "Column not found: %s.%s".formatted( + name, + columnName + ) + )); + } } diff --git a/core/src/test/java/dev/suprim/query/model/DbTableTest.java b/core/src/test/java/dev/suprim/query/model/DbTableTest.java index a4f9eb9..a504883 100644 --- a/core/src/test/java/dev/suprim/query/model/DbTableTest.java +++ b/core/src/test/java/dev/suprim/query/model/DbTableTest.java @@ -10,6 +10,13 @@ class DbTableTest { + private static final DbColumn DATA_COLUMN = new DbColumn( + "users", "data", "", "t", false, "jsonb", false, false, String.class, "\"", "" + ); + private static final DbTable JSONB_TABLE = new DbTable( + "public", "users", "public.users", "t", List.of(DATA_COLUMN), "TABLE", "\"" + ); + @Test void render_shouldReturnFullNameAndAlias() { DbTable table = createTable(); @@ -55,54 +62,39 @@ void buildColumn_withAlias_shouldSetAlias() throws DbException { @Test void buildColumn_withJsonArrowOperator_shouldParseCorrectly() throws DbException { - DbColumn col = createColumn("data", false); - DbTable table = new DbTable("public", "users", "public.users", "t", List.of(col), "TABLE", "\""); - - DbColumn result = table.buildColumn("data->>name"); + DbColumn result = JSONB_TABLE.buildColumn("data->>name"); assertThat(result.name()).isEqualTo("data"); - assertThat(result.jsonParts()).isEqualTo("->>name"); + assertThat(result.jsonParts()).isEqualTo("->>'name'"); } @Test void buildColumn_withJsonSingleArrow_shouldParseCorrectly() throws DbException { - DbColumn col = createColumn("data", false); - DbTable table = new DbTable("public", "users", "public.users", "t", List.of(col), "TABLE", "\""); - - DbColumn result = table.buildColumn("data->nested"); + DbColumn result = JSONB_TABLE.buildColumn("data->nested"); assertThat(result.name()).isEqualTo("data"); - assertThat(result.jsonParts()).isEqualTo("->nested"); + assertThat(result.jsonParts()).isEqualTo("->'nested'"); } @Test void buildColumn_withJsonHashDoubleArrow_shouldParseCorrectly() throws DbException { - DbColumn col = createColumn("data", false); - DbTable table = new DbTable("public", "users", "public.users", "t", List.of(col), "TABLE", "\""); - - DbColumn result = table.buildColumn("data#>>nested.field"); + DbColumn result = JSONB_TABLE.buildColumn("data#>>nested.field"); assertThat(result.name()).isEqualTo("data"); - assertThat(result.jsonParts()).isEqualTo("#>>nested,field"); + assertThat(result.jsonParts()).isEqualTo("#>>'{nested,field}'"); } @Test void buildColumn_withJsonHashSingleArrow_shouldParseCorrectly() throws DbException { - DbColumn col = createColumn("data", false); - DbTable table = new DbTable("public", "users", "public.users", "t", List.of(col), "TABLE", "\""); - - DbColumn result = table.buildColumn("data#>nested.field"); + DbColumn result = JSONB_TABLE.buildColumn("data#>nested.field"); assertThat(result.name()).isEqualTo("data"); - assertThat(result.jsonParts()).isEqualTo("#>nested,field"); + assertThat(result.jsonParts()).isEqualTo("#>'{nested,field}'"); } @Test void buildColumn_withDoubleAsterisk_shouldParseCorrectly() throws DbException { - DbColumn col = createColumn("data", false); - DbTable table = new DbTable("public", "users", "public.users", "t", List.of(col), "TABLE", "\""); - - DbColumn result = table.buildColumn("data**name"); + DbColumn result = JSONB_TABLE.buildColumn("data**name"); assertThat(result.name()).isEqualTo("data"); assertThat(result.jsonParts()).isEqualTo("->>'name'"); @@ -110,10 +102,7 @@ void buildColumn_withDoubleAsterisk_shouldParseCorrectly() throws DbException { @Test void buildColumn_withSingleAsterisk_shouldParseCorrectly() throws DbException { - DbColumn col = createColumn("data", false); - DbTable table = new DbTable("public", "users", "public.users", "t", List.of(col), "TABLE", "\""); - - DbColumn result = table.buildColumn("data*name"); + DbColumn result = JSONB_TABLE.buildColumn("data*name"); assertThat(result.name()).isEqualTo("data"); assertThat(result.jsonParts()).isEqualTo("->'name'"); @@ -146,6 +135,145 @@ void buildColumn_withNonExistentColumn_shouldThrowDbException() { .hasMessageContaining("Column not found: users.nonexistent"); } + // --- Multi-level JSONB path tests --- + + @Test + void buildColumn_withMultiLevelArrow_shouldParseChainedOperators() throws DbException { + DbColumn result = JSONB_TABLE.buildColumn("data->'feedback'->>'type'"); + + assertThat(result.name()).isEqualTo("data"); + assertThat(result.jsonParts()).isEqualTo("->'feedback'->>'type'"); + } + + @Test + void buildColumn_withMultiLevelUnquotedArrow_shouldAutoQuoteKeys() throws DbException { + DbColumn result = JSONB_TABLE.buildColumn("data->feedback->>type"); + + assertThat(result.name()).isEqualTo("data"); + assertThat(result.jsonParts()).isEqualTo("->'feedback'->>'type'"); + } + + @Test + void buildColumn_withThreeLevelArrow_shouldParseAllLevels() throws DbException { + DbColumn col = createColumn("meta", false); + DbTable table = new DbTable("public", "events", "public.events", "e", List.of(col), "TABLE", "\""); + + DbColumn result = table.buildColumn("meta->config->settings->>theme"); + + assertThat(result.name()).isEqualTo("meta"); + assertThat(result.jsonParts()).isEqualTo("->'config'->'settings'->>'theme'"); + } + + @Test + void buildColumn_withMultiLevelDoubleAsterisk_shouldChainTextExtraction() throws DbException { + DbColumn result = JSONB_TABLE.buildColumn("data**feedback**type"); + + assertThat(result.name()).isEqualTo("data"); + assertThat(result.jsonParts()).isEqualTo("->'feedback'->>'type'"); + } + + @Test + void buildColumn_withMultiLevelSingleAsterisk_shouldChainObjectExtraction() throws DbException { + DbColumn result = JSONB_TABLE.buildColumn("data*feedback*type"); + + assertThat(result.name()).isEqualTo("data"); + assertThat(result.jsonParts()).isEqualTo("->'feedback'->'type'"); + } + + @Test + void buildColumn_withMultiLevelHashPath_shouldProducePostgresPathArray() throws DbException { + DbColumn result = JSONB_TABLE.buildColumn("data#>>feedback.type.value"); + + assertThat(result.name()).isEqualTo("data"); + assertThat(result.jsonParts()).isEqualTo("#>>'{feedback,type,value}'"); + } + + @Test + void buildColumn_withMultiLevelArrowAndAlias_shouldSetAlias() throws DbException { + DbColumn result = JSONB_TABLE.buildColumn("data->feedback->>type:feedback_type"); + + assertThat(result.name()).isEqualTo("data"); + assertThat(result.alias()).isEqualTo("feedback_type"); + assertThat(result.jsonParts()).isEqualTo("->'feedback'->>'type'"); + } + + @Test + void buildColumn_withTrailingDoubleAsterisk_shouldThrowDbException() { + assertThatThrownBy(() -> JSONB_TABLE.buildColumn("data**")) + .isInstanceOf(DbException.class) + .hasMessageContaining("trailing delimiter"); + } + + @Test + void buildColumn_withTrailingSingleAsterisk_shouldThrowDbException() { + assertThatThrownBy(() -> JSONB_TABLE.buildColumn("data*")) + .isInstanceOf(DbException.class) + .hasMessageContaining("trailing delimiter"); + } + + @Test + void buildColumn_withUnclosedQuote_shouldThrowDbException() { + assertThatThrownBy(() -> JSONB_TABLE.buildColumn("data->'feedback")) + .isInstanceOf(DbException.class) + .hasMessageContaining("Unclosed quote"); + } + + @Test + void buildColumn_withMultipleColons_shouldUseFirstColonOnly() throws DbException { + DbColumn result = JSONB_TABLE.buildColumn("data->>name:alias:extra"); + + assertThat(result.name()).isEqualTo("data"); + assertThat(result.alias()).isEqualTo("alias:extra"); + } + + @Test + void buildColumn_withArrowAndEmptyKey_shouldProduceEmptyQuotedKey() throws DbException { + DbColumn result = JSONB_TABLE.buildColumn("data->>"); + + assertThat(result.name()).isEqualTo("data"); + assertThat(result.jsonParts()).isEqualTo("->>''"); + } + + @Test + void buildColumn_withSingleCharKey_shouldNotBeConsideredQuoted() throws DbException { + DbColumn result = JSONB_TABLE.buildColumn("data->>x"); + + assertThat(result.name()).isEqualTo("data"); + assertThat(result.jsonParts()).isEqualTo("->>'x'"); + } + + @Test + void buildColumn_withNoJsonOperator_shouldReturnPlainColumn() throws DbException { + DbColumn result = JSONB_TABLE.buildColumn("data"); + + assertThat(result.name()).isEqualTo("data"); + assertThat(result.jsonParts()).isEmpty(); + } + + @Test + void buildColumn_withTrailingArrow_shouldProduceEmptyKey() throws DbException { + DbColumn result = JSONB_TABLE.buildColumn("data->"); + + assertThat(result.name()).isEqualTo("data"); + assertThat(result.jsonParts()).isEqualTo("->''"); + } + + @Test + void buildColumn_withTrailingDashAfterKey_shouldIncludeDashInKey() throws DbException { + DbColumn result = JSONB_TABLE.buildColumn("data->name-"); + + assertThat(result.name()).isEqualTo("data"); + assertThat(result.jsonParts()).isEqualTo("->'name-'"); + } + + @Test + void buildColumn_withTrailingCharsAfterQuotedKey_shouldStopParsing() throws DbException { + DbColumn result = JSONB_TABLE.buildColumn("data->'x'z"); + + assertThat(result.name()).isEqualTo("data"); + assertThat(result.jsonParts()).isEqualTo("->'x'"); + } + @Test void buildColumns_shouldReturnAllColumns() { DbColumn col1 = createColumn("id", true); diff --git a/postgresql/src/main/java/dev/suprim/query/postgresql/PostgreSQLDataExclusion.java b/postgresql/src/main/java/dev/suprim/query/postgresql/PostgreSQLDataExclusion.java index 6f7fb46..b8260aa 100644 --- a/postgresql/src/main/java/dev/suprim/query/postgresql/PostgreSQLDataExclusion.java +++ b/postgresql/src/main/java/dev/suprim/query/postgresql/PostgreSQLDataExclusion.java @@ -117,15 +117,17 @@ private DbTable getDbTable(DatabaseMetaData databaseMetaData, MetaDataTable meta ? schema : metaDataTable.catalog(); - return new DbTable( - schemaName, - metaDataTable.tableName(), - schemaName + "." + metaDataTable.tableName(), - metaDataTable.tableAlias(), - columns, - metaDataTable.tableType(), - "\"" - ); + return DbTable.builder() + .schema(schemaName) + .name(metaDataTable.tableName()) + .fullName( + schemaName + "." + metaDataTable.tableName() + ) + .alias(metaDataTable.tableAlias()) + .dbColumns(columns) + .type(metaDataTable.tableType()) + .coverChar("\"") + .build(); } catch (SQLException e) { throw new RuntimeException(e); }