From fbeb50cef1c4be8440b4e12b4483d14c92f303ff Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Wed, 17 Jun 2026 10:25:08 -0700 Subject: [PATCH 1/2] Fix #2462: support filtering on counter columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A table with a counter column was described with apiSupport.filter:true, but filtering on a counter actually failed at runtime with INVALID_FILTER_COLUMN_VALUES. The describe output was correct — counters ARE filterable in CQL via ALLOW FILTERING (they just cannot be indexed) — the gap was a missing JSON->CQL codec. Filter literals arrive as BigDecimal/BigInteger, but COUNTER only registered COUNTER_FROM_LONG, so codec lookup threw ToCQLCodecException. Add COUNTER_FROM_BIG_DECIMAL and COUNTER_FROM_BIG_INTEGER (mirroring BIGINT's longValueExact variants) and register them. With the codecs in place, WhereCQLClauseAnalyzer proceeds with ALLOW FILTERING and emits a MISSING_INDEX warning, so apiSupport.filter stays true. This reverses the earlier flip-to-false approach: keep filter=true and make the runtime match it. insert/update remain unsupported (insert=false / Update.NONE), and isUnsupportedDML() is unchanged (already true via insert=false). Tests: - JSONCodecRegistryTest: add COUNTER cases for Long/BigInteger/BigDecimal -> Long binding. - UnsupportedTypeTableIntegrationTest.Counter: assert describe reports filter:true; rewrite filterCounter to seed a counter via raw CQL and expect the row back with a MISSING_INDEX warning. --- .../table/codecs/JSONCodecRegistries.java | 2 + .../filters/table/codecs/JSONCodecs.java | 16 +++++++- .../UnsupportedTypeTableIntegrationTest.java | 41 ++++++++++++++++--- .../api/v1/util/DataApiResponseValidator.java | 20 +++++++++ .../table/codecs/JSONCodecRegistryTest.java | 4 ++ 5 files changed, 76 insertions(+), 7 deletions(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/filters/table/codecs/JSONCodecRegistries.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/filters/table/codecs/JSONCodecRegistries.java index ef9ea68ed5..e4d9e584a5 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/filters/table/codecs/JSONCodecRegistries.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/filters/table/codecs/JSONCodecRegistries.java @@ -28,6 +28,8 @@ private JSONCodecRegistries() {} JSONCodecs.BIGINT_FROM_BIG_DECIMAL, JSONCodecs.BIGINT_FROM_BIG_INTEGER, JSONCodecs.BIGINT_FROM_LONG, + JSONCodecs.COUNTER_FROM_BIG_DECIMAL, + JSONCodecs.COUNTER_FROM_BIG_INTEGER, JSONCodecs.COUNTER_FROM_LONG, JSONCodecs.INT_FROM_BIG_DECIMAL, JSONCodecs.INT_FROM_BIG_INTEGER, diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/filters/table/codecs/JSONCodecs.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/filters/table/codecs/JSONCodecs.java index 43b75a8cfd..b5cc8d0ca4 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/filters/table/codecs/JSONCodecs.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/filters/table/codecs/JSONCodecs.java @@ -60,7 +60,21 @@ public abstract class JSONCodecs { JSONCodec.ToCQL.unsafeIdentity(), JSONCodec.ToJSON.unsafeNodeFactory(JsonNodeFactory.instance::numberNode)); - // we can only read counters from CQL, do not support writing them + // Counters are read/filter-only; mirror BIGINT's variants so filters can bind them (#2462). + public static final JSONCodec COUNTER_FROM_BIG_DECIMAL = + new JSONCodec<>( + GenericType.BIG_DECIMAL, + DataTypes.COUNTER, + JSONCodec.ToCQL.safeNumber(BigDecimal::longValueExact), + JSONCodec.ToJSON.unsafeNodeFactory(JsonNodeFactory.instance::numberNode)); + + public static final JSONCodec COUNTER_FROM_BIG_INTEGER = + new JSONCodec<>( + GenericType.BIG_INTEGER, + DataTypes.COUNTER, + JSONCodec.ToCQL.safeNumber(BigInteger::longValueExact), + JSONCodec.ToJSON.unsafeNodeFactory(JsonNodeFactory.instance::numberNode)); + public static final JSONCodec COUNTER_FROM_LONG = new JSONCodec<>( GenericType.LONG, diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/UnsupportedTypeTableIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/UnsupportedTypeTableIntegrationTest.java index a7ee8a0c9e..54928a72fe 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/UnsupportedTypeTableIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/UnsupportedTypeTableIntegrationTest.java @@ -161,6 +161,19 @@ public final void createDefaultTablesAndIndexes() { assertThat(executeCqlStatement(createTable.build())).isTrue(); } + // Regression test for #2462: a counter column must report apiSupport.filter == true in the + // table description, because counters ARE filterable via ALLOW FILTERING (they just cannot be + // indexed). Filtering succeeds with a MISSING_INDEX warning (see filterCounter() below). + @Test + @Order(2) + public final void listTablesReportsCounterFilterable() { + assertNamespaceCommand(keyspaceName) + .templated() + .listTables(true) + .wasSuccessful() + .hasTableColumnApiSupport(TABLE_COUNTER, "counter", false, false, true, true); + } + // In Cassandra, you cannot use an INSERT statement directly for counters. // Instead, counter columns require a special kind of update operation. // Counters in Cassandra are designed to increment or decrement a value, @@ -206,17 +219,33 @@ public final void updateCounter() { "The operation was not supported by the columns: counter(counter)."); } - // TODO filter on counter, INVALID_FILTER_COLUMN_VALUES + // #2462: counters are filterable via ALLOW FILTERING. Counters cannot be inserted through the + // API, so seed a value with raw CQL (UPDATE ... counter = counter + N), then confirm the filter + // binds the value, returns the row, and emits a MISSING_INDEX warning (counters can't be + // indexed). @Test @Order(4) public final void filterCounter() { + // keyspace/table are case-sensitive identifiers, so quote them in the raw CQL. + var ksCql = + CqlIdentifierUtil.cqlIdentifierToCQL( + CqlIdentifierUtil.cqlIdentifierFromUserInput(keyspaceName)); + var tableCql = + CqlIdentifierUtil.cqlIdentifierToCQL( + CqlIdentifierUtil.cqlIdentifierFromUserInput(TABLE_COUNTER)); + assertThat( + executeCqlStatement( + SimpleStatement.newInstance( + "UPDATE %s.%s SET counter = counter + 5 WHERE id = '1'" + .formatted(ksCql, tableCql)))) + .isTrue(); + assertTableCommand(keyspaceName, TABLE_COUNTER) .templated() - .findOne(ImmutableMap.of("counter", 1), null) - .hasSingleApiError( - FilterException.Code.INVALID_FILTER_COLUMN_VALUES, - FilterException.class, - "Only values that are supported by the column data type can be included"); + .findOne(ImmutableMap.of("counter", 5), null) + .hasNoErrors() + .hasSingleWarning(WarningException.Code.MISSING_INDEX) + .body("data.document.id", is("1")); } } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/util/DataApiResponseValidator.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/util/DataApiResponseValidator.java index f471786523..316dd0db42 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/util/DataApiResponseValidator.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/util/DataApiResponseValidator.java @@ -434,6 +434,26 @@ public DataApiResponseValidator doesNotHaveIndexes(String... indexes) { return toReturn; } + /** + * Asserts the {@code apiSupport} flags reported for a column in a {@code listTables} (with + * explain) response, e.g. {@code status.tables[name].definition.columns[column].apiSupport}. + */ + public DataApiResponseValidator hasTableColumnApiSupport( + String tableName, + String columnName, + boolean createTable, + boolean insert, + boolean read, + boolean filter) { + var apiSupportPath = + "status.tables.find { it.name == '%s' }.definition.columns.%s.apiSupport" + .formatted(tableName, columnName); + return body(apiSupportPath + ".createTable", is(createTable)) + .body(apiSupportPath + ".insert", is(insert)) + .body(apiSupportPath + ".read", is(read)) + .body(apiSupportPath + ".filter", is(filter)); + } + public DataApiResponseValidator hasNextPageState() { return body("data.nextPageState", is(notNullValue())); } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/service/operation/filters/table/codecs/JSONCodecRegistryTest.java b/src/test/java/io/stargate/sgv2/jsonapi/service/operation/filters/table/codecs/JSONCodecRegistryTest.java index 3cb9043840..b010531449 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/service/operation/filters/table/codecs/JSONCodecRegistryTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/service/operation/filters/table/codecs/JSONCodecRegistryTest.java @@ -204,6 +204,10 @@ private static Stream validCodecToCQLTestCasesInt() { Arguments.of(DataTypes.BIGINT, -456L, -456L), Arguments.of(DataTypes.BIGINT, BigInteger.valueOf(123), 123L), Arguments.of(DataTypes.BIGINT, BigDecimal.valueOf(999.0), 999L), + // counters are filterable (#2462): they bind the same 3 Java types as BIGINT + Arguments.of(DataTypes.COUNTER, -456L, -456L), + Arguments.of(DataTypes.COUNTER, BigInteger.valueOf(123), 123L), + Arguments.of(DataTypes.COUNTER, BigDecimal.valueOf(999.0), 999L), Arguments.of(DataTypes.INT, -42000L, -42000), Arguments.of(DataTypes.INT, BigInteger.valueOf(19000), 19000), Arguments.of(DataTypes.INT, BigDecimal.valueOf(23456.0), 23456), From 42a57a5ede7650c142ca6537daafc8f07636e8b7 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Tue, 27 Oct 2026 13:12:32 -0700 Subject: [PATCH 2/2] Clean up counter filter regression coverage --- .../operation/filters/table/codecs/JSONCodecs.java | 2 +- .../tables/UnsupportedTypeTableIntegrationTest.java | 12 +++--------- .../filters/table/codecs/JSONCodecRegistryTest.java | 7 ++++++- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/filters/table/codecs/JSONCodecs.java b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/filters/table/codecs/JSONCodecs.java index b5cc8d0ca4..e76adee525 100644 --- a/src/main/java/io/stargate/sgv2/jsonapi/service/operation/filters/table/codecs/JSONCodecs.java +++ b/src/main/java/io/stargate/sgv2/jsonapi/service/operation/filters/table/codecs/JSONCodecs.java @@ -60,7 +60,7 @@ public abstract class JSONCodecs { JSONCodec.ToCQL.unsafeIdentity(), JSONCodec.ToJSON.unsafeNodeFactory(JsonNodeFactory.instance::numberNode)); - // Counters are read/filter-only; mirror BIGINT's variants so filters can bind them (#2462). + // Counter columns support reads and filters, but not writes through the Data API. public static final JSONCodec COUNTER_FROM_BIG_DECIMAL = new JSONCodec<>( GenericType.BIG_DECIMAL, diff --git a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/UnsupportedTypeTableIntegrationTest.java b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/UnsupportedTypeTableIntegrationTest.java index 54928a72fe..37d1492bb9 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/UnsupportedTypeTableIntegrationTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/api/v1/tables/UnsupportedTypeTableIntegrationTest.java @@ -161,9 +161,6 @@ public final void createDefaultTablesAndIndexes() { assertThat(executeCqlStatement(createTable.build())).isTrue(); } - // Regression test for #2462: a counter column must report apiSupport.filter == true in the - // table description, because counters ARE filterable via ALLOW FILTERING (they just cannot be - // indexed). Filtering succeeds with a MISSING_INDEX warning (see filterCounter() below). @Test @Order(2) public final void listTablesReportsCounterFilterable() { @@ -219,14 +216,10 @@ public final void updateCounter() { "The operation was not supported by the columns: counter(counter)."); } - // #2462: counters are filterable via ALLOW FILTERING. Counters cannot be inserted through the - // API, so seed a value with raw CQL (UPDATE ... counter = counter + N), then confirm the filter - // binds the value, returns the row, and emits a MISSING_INDEX warning (counters can't be - // indexed). + // Seed with CQL because the Data API does not support counter writes. @Test @Order(4) public final void filterCounter() { - // keyspace/table are case-sensitive identifiers, so quote them in the raw CQL. var ksCql = CqlIdentifierUtil.cqlIdentifierToCQL( CqlIdentifierUtil.cqlIdentifierFromUserInput(keyspaceName)); @@ -245,7 +238,8 @@ public final void filterCounter() { .findOne(ImmutableMap.of("counter", 5), null) .hasNoErrors() .hasSingleWarning(WarningException.Code.MISSING_INDEX) - .body("data.document.id", is("1")); + .body("data.document.id", is("1")) + .body("data.document.counter", is(5)); } } diff --git a/src/test/java/io/stargate/sgv2/jsonapi/service/operation/filters/table/codecs/JSONCodecRegistryTest.java b/src/test/java/io/stargate/sgv2/jsonapi/service/operation/filters/table/codecs/JSONCodecRegistryTest.java index b010531449..56f54bb525 100644 --- a/src/test/java/io/stargate/sgv2/jsonapi/service/operation/filters/table/codecs/JSONCodecRegistryTest.java +++ b/src/test/java/io/stargate/sgv2/jsonapi/service/operation/filters/table/codecs/JSONCodecRegistryTest.java @@ -204,7 +204,6 @@ private static Stream validCodecToCQLTestCasesInt() { Arguments.of(DataTypes.BIGINT, -456L, -456L), Arguments.of(DataTypes.BIGINT, BigInteger.valueOf(123), 123L), Arguments.of(DataTypes.BIGINT, BigDecimal.valueOf(999.0), 999L), - // counters are filterable (#2462): they bind the same 3 Java types as BIGINT Arguments.of(DataTypes.COUNTER, -456L, -456L), Arguments.of(DataTypes.COUNTER, BigInteger.valueOf(123), 123L), Arguments.of(DataTypes.COUNTER, BigDecimal.valueOf(999.0), 999L), @@ -910,6 +909,11 @@ private static Stream outOfRangeOfCqlNumberTestCases() { DataTypes.BIGINT, TEST_DATA.OUT_OF_RANGE_FOR_BIGINT.toBigIntegerExact(), "BigInteger out of long range"), + Arguments.of(DataTypes.COUNTER, TEST_DATA.OUT_OF_RANGE_FOR_BIGINT, "Overflow"), + Arguments.of( + DataTypes.COUNTER, + TEST_DATA.OUT_OF_RANGE_FOR_BIGINT.toBigIntegerExact(), + "BigInteger out of long range"), Arguments.of(DataTypes.INT, TEST_DATA.OVERFLOW_FOR_INT, "Overflow"), Arguments.of( DataTypes.INT, @@ -953,6 +957,7 @@ private static Stream nonExactToCqlIntegerTestCases() { // Arguments: (DataType, Number-not-exact-as-integer) return Stream.of( Arguments.of(DataTypes.BIGINT, TEST_DATA.NOT_EXACT_AS_INTEGER), + Arguments.of(DataTypes.COUNTER, TEST_DATA.NOT_EXACT_AS_INTEGER), Arguments.of(DataTypes.INT, TEST_DATA.NOT_EXACT_AS_INTEGER), Arguments.of(DataTypes.SMALLINT, TEST_DATA.NOT_EXACT_AS_INTEGER), Arguments.of(DataTypes.TINYINT, TEST_DATA.NOT_EXACT_AS_INTEGER),