diff --git a/examples/school-management/src/main/java/com/example/schoolmanagementservice/App.java b/examples/school-management/src/main/java/com/example/schoolmanagementservice/App.java index 3c12132d..da31b768 100644 --- a/examples/school-management/src/main/java/com/example/schoolmanagementservice/App.java +++ b/examples/school-management/src/main/java/com/example/schoolmanagementservice/App.java @@ -184,7 +184,7 @@ public CommandLineRunner teaQLConsoleStartup( .comment("Facet SchoolType values including zero-count constants") .purpose("Verify Java native SQLite Facet semantics") .executeForList(context); - SmartList allValues = includeAllFacet.getFacet("schoolTypeFacet"); + SmartList allValues = includeAllFacet.getFacet("schoolTypeFacet").map(SchoolType.class::cast); require(allValues != null && allValues.size() == 2, "include-all SchoolType facet did not retain both constants"); Number primaryCount = allValues.get(0).getDynamicProperty("schoolCount"); @@ -205,7 +205,7 @@ public CommandLineRunner teaQLConsoleStartup( .comment("Facet only SchoolType values matched by the outer School filter") .purpose("Verify Java native SQLite matched-only Facet semantics") .executeForList(context); - SmartList matchedValues = matchedOnlyFacet.getFacet("schoolTypeFacet"); + SmartList matchedValues = matchedOnlyFacet.getFacet("schoolTypeFacet").map(SchoolType.class::cast); require(matchedValues != null && matchedValues.size() == 1, "matched-only SchoolType facet must contain one value"); require("PRIMARY".equals(matchedValues.get(0).getCode()) diff --git a/examples/security-foundations/src/test/java/io/teaql/examples/security/SqlRelationMaskingTest.java b/examples/security-foundations/src/test/java/io/teaql/examples/security/SqlRelationMaskingTest.java index 1fd0bac5..9e618c01 100644 --- a/examples/security-foundations/src/test/java/io/teaql/examples/security/SqlRelationMaskingTest.java +++ b/examples/security-foundations/src/test/java/io/teaql/examples/security/SqlRelationMaskingTest.java @@ -137,9 +137,10 @@ private static SQLEntityDescriptor descriptor(String type, Class assertNull("Aggregate results must not overwrite the native namespace", rows.get(0).getProperty("childCount")); } else if (shape.equals("facet")) { - SmartList parents = rows.getFacet("parentFacet"); + SmartList parents = rows.getFacet("parentFacet"); assertEquals(1, parents.size()); - assertEquals("Riverside", parents.get(0).getProperty("name")); + assertTrue(parents.get(0) instanceof Entity); + assertEquals("Riverside", ((Entity) parents.get(0)).getProperty("name")); } else if (forward) { Entity loadedParent = rows.get(0).getProperty("parent"); diff --git a/examples/standard-order/src/test/java/com/example/requesttreeorderservice/StandardOrderTelemetryTest.java b/examples/standard-order/src/test/java/com/example/requesttreeorderservice/StandardOrderTelemetryTest.java index ef9930c3..e3e4052f 100644 --- a/examples/standard-order/src/test/java/com/example/requesttreeorderservice/StandardOrderTelemetryTest.java +++ b/examples/standard-order/src/test/java/com/example/requesttreeorderservice/StandardOrderTelemetryTest.java @@ -109,13 +109,18 @@ private static void verifyPage(SmartList rows) { assertEquals("Box 1", E.customerOrder(row).getOrderItemList().first().getProductName().eval()); assertEquals(5, ((Number) row.getDynamicProperty("itemCount")).intValue()); var facets = rows.getFacet("statusCounts"); assertEquals(3, facets.size()); - int[] counts = {1, 2, 0}, sums = {100, 500, 0}; + int[] counts = {1, 2, 0}; + Integer[] sums = {100, 500, null}; String[] names = {"New", "Paid", "Cancelled"}; for (int index = 0; index < 3; index++) { var facet = (OrderStatus) facets.get(index); assertEquals(names[index], E.orderStatus(facet).getName().eval()); assertEquals(counts[index], ((Number) facet.getDynamicProperty("orderCount")).intValue()); - assertEquals(0, new BigDecimal(facet.getDynamicProperty("orderTotalAmount").toString()).compareTo(BigDecimal.valueOf(sums[index]))); + if (sums[index] == null) { + assertNull(facet.getDynamicProperty("orderTotalAmount")); + } else { + assertEquals(0, new BigDecimal(facet.getDynamicProperty("orderTotalAmount").toString()).compareTo(BigDecimal.valueOf(sums[index]))); + } } var unloaded = assertThrows(RuntimeException.class, () -> E.customerOrder(row).getTotalAmount().eval()); assertTrue("an omitted amount must be NotLoaded, not null", unloaded.getClass().getSimpleName().contains("NotLoaded")); diff --git a/teaql-core/src/main/java/io/teaql/core/SmartList.java b/teaql-core/src/main/java/io/teaql/core/SmartList.java index 2a23a4ce..7a941345 100644 --- a/teaql-core/src/main/java/io/teaql/core/SmartList.java +++ b/teaql-core/src/main/java/io/teaql/core/SmartList.java @@ -2,6 +2,8 @@ import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.Iterator; import java.util.List; import java.util.Map; @@ -15,9 +17,10 @@ import io.teaql.core.utils.MapUtil; import io.teaql.core.utils.ObjectUtil; -public class SmartList implements Iterable { +public class SmartList implements Iterable { private static final Map, SmartList> TYPED_EMPTY = new java.util.concurrent.ConcurrentHashMap<>(); + private static final SmartList UNLOADED = new SmartList<>(true, false); List data; @@ -26,7 +29,9 @@ public class SmartList implements Iterable { @FrameworkInternal("Named page total metadata, independent of physical count SQL") public void __internalSetPageCountAlias(String alias) { ensureMutable(); pageCountAlias = alias; } - Map facets; + Map> facets; + private Map summary; + private boolean loaded = true; private final boolean immutable; @@ -48,17 +53,24 @@ public SmartList(List data) { } } - private SmartList(boolean immutable) { + private SmartList(boolean immutable, boolean loaded) { this.immutable = immutable; + this.loaded = loaded; data = List.of(); } @SuppressWarnings("unchecked") public static SmartList empty(Class entityType) { if (entityType == null) throw new IllegalArgumentException("entityType must not be null"); - return (SmartList) TYPED_EMPTY.computeIfAbsent(entityType, ignored -> new SmartList<>(true)); + return (SmartList) TYPED_EMPTY.computeIfAbsent(entityType, ignored -> new SmartList<>(true, true)); } + /** Unloaded relation marker; successful empty queries use a loaded empty list. */ + @SuppressWarnings("unchecked") + public static SmartList unloaded() { return (SmartList) UNLOADED; } + + public boolean isLoaded() { return loaded; } + @FrameworkInternal public static SmartList takeOwnership(List data) { SmartList result = new SmartList<>(); @@ -93,7 +105,20 @@ public Map identityMap(Function key) { } public Map mapById() { - return identityMap(Entity::getId); + Map result = new LinkedHashMap<>(); + for (T item : data) { + Long id = entity(item).getId(); + if (id != null) result.put(id, item); + } + return result; + } + + public List ids() { return toList(item -> entity(item).getId()); } + public List versions() { return toList(item -> entity(item).getVersion()); } + + private static Entity entity(Object item) { + if (item instanceof Entity entity) return entity; + throw new IllegalStateException("Entity identity operations require entity rows"); } public Map> groupBy(Function key) { @@ -189,35 +214,46 @@ public T get(int index) { return data.get(index); } + public T getOrNull(int index) { return index >= 0 && index < size() ? data.get(index) : null; } + public T last() { return isEmpty() ? null : data.get(size() - 1); } + public SmartList save(UserContext userContext) { userContext.saveGraph(this); return this; } - public int getTotalCount() { - if (ObjectUtil.isEmpty(aggregationResults)) { - return size(); - } - Map numberProps = aggregationNumberProperties(); - if (numberProps.isEmpty()) { - return size(); - } - Object count = numberProps.get(pageCountAlias); - if (count instanceof Number intCount) { - return intCount.intValue(); + /** Exact source-query total, or null when no count was requested. */ + public Long getExactTotalCount() { + Map properties = aggregationProperties(); + if (!properties.containsKey(pageCountAlias)) return null; + Object count = properties.get(pageCountAlias); + try { + if (!(count instanceof Number)) throw new ArithmeticException(); + long exact = new java.math.BigDecimal(count.toString()).longValueExact(); + if (exact < 0) throw new ArithmeticException(); + return exact; + } catch (ArithmeticException | NumberFormatException invalid) { + throw new IllegalStateException("List count must be an exact non-negative long", invalid); } - throw new IllegalStateException("Number prop is expected a number, but it is now a " + count.getClass().getSimpleName()); + } - //return aggregationResults.get(0).toInt(); + public long getTotalCountOrSize() { + Long exact = getExactTotalCount(); + return exact == null ? size() : exact; } + /** @deprecated Use getExactTotalCount, or the explicitly named getTotalCountOrSize fallback. */ + @Deprecated + public int getTotalCount() { return Math.toIntExact(getTotalCountOrSize()); } + public Map aggregationProperties(Class clazz) { if (ObjectUtil.isEmpty(aggregationResults)) { return MapUtil.empty(); } Map result = MapUtil.createMap(HashMap.class); getAggregationResults().forEach(aggregationResult -> { + if (aggregationResult.getData() == null) return; //Map s=aggregationResult.toSimpleMap(); List> resultList = aggregationResult.valueList(); @@ -225,7 +261,8 @@ public Map aggregationProperties(Class clazz) { resultList.forEach(map -> { map.entrySet().forEach(stringObjectEntry -> { - if (clazz.isAssignableFrom(stringObjectEntry.getValue().getClass())) { + if (stringObjectEntry.getValue() == null ? clazz == Object.class + : clazz.isInstance(stringObjectEntry.getValue())) { result.put(stringObjectEntry.getKey(), stringObjectEntry.getValue()); } @@ -236,25 +273,25 @@ public Map aggregationProperties(Class clazz) { return result; } - public void addFacet(String name, SmartList facet) { + public void addFacet(String name, SmartList facet) { ensureMutable(); mutableFacets().put(name, facet); } - public Map getFacets() { + public Map> getFacets() { return mutableFacets(); } - public void setFacets(Map facets) { + public void setFacets(Map> facets) { ensureMutable(); - this.facets = facets; + this.facets = facets == null ? null : new LinkedHashMap<>(facets); } - public SmartList getFacet(String name) { + public SmartList getFacet(String name) { return facets == null ? null : facets.get(name); } - public SmartList removeFacet(String name) { + public SmartList removeFacet(String name) { ensureMutable(); return facets == null ? null : facets.remove(name); } @@ -264,15 +301,30 @@ public void clearFacets() { if (facets != null) facets.clear(); } + public Map> takeFacets() { + ensureMutable(); + Map> result = facets == null ? new LinkedHashMap<>() : facets; + facets = null; + return result; + } + + /** Summary population stays package-private; consumers receive a detached value tree. */ + void setSummary(Map values) { + ensureMutable(); + summary = copyMap(values); + } + + public Map getSummary() { return copyMap(summary); } + private List mutableAggregationResults() { if (immutable) return List.of(); if (aggregationResults == null) aggregationResults = new ArrayList<>(); return aggregationResults; } - private Map mutableFacets() { + private Map> mutableFacets() { if (immutable) return Map.of(); - if (facets == null) facets = new HashMap<>(); + if (facets == null) facets = new LinkedHashMap<>(); return facets; } @@ -302,6 +354,85 @@ public boolean removeIf(Predicate filter) { return data.removeIf(filter); } + /** One output row per source row, with an isolated query metadata snapshot. */ + public SmartList map(Function mapper) { + SmartList result = new SmartList<>(toList(mapper::apply)); + result.loaded = loaded; + result.pageCountAlias = pageCountAlias; + result.summary = copyMap(summary); + if (aggregationResults != null) { + result.aggregationResults = new ArrayList<>(); + for (AggregationResult aggregation : aggregationResults) { + AggregationResult copy = new AggregationResult(); + copy.setName(aggregation.getName()); + List items = new ArrayList<>(); + if (aggregation.getData() != null) for (AggregationItem item : aggregation.getData()) { + AggregationItem copied = new AggregationItem(); + item.getValues().forEach((key, value) -> copied.addValue(key, copyValue(value))); + item.getDimensions().forEach((key, value) -> copied.addDimension(key, copyValue(value))); + items.add(copied); + } + copy.setData(items); + result.aggregationResults.add(copy); + } + } + if (facets != null) { + result.facets = new LinkedHashMap<>(); + facets.forEach((name, facet) -> result.facets.put(name, facet.map(SmartList::copyValue))); + } + return result; + } + + public SmartList filter(Predicate predicate) { + SmartList result = new SmartList<>(data.stream().filter(predicate).toList()); + result.loaded = loaded; + return result; + } + + public SmartList flatMap(Function> mapper) { + SmartList result = new SmartList<>(); + for (T item : data) for (R value : mapper.apply(item)) result.add(value); + result.loaded = loaded; + return result; + } + + public void mergeBy(Iterable incoming, Function key) { + ensureMutable(); + Map positions = new HashMap<>(); + for (int index = 0; index < size(); index++) positions.put(key.apply(data.get(index)), index); + for (T item : incoming) { + K itemKey = key.apply(item); + Integer position = positions.get(itemKey); + if (position != null) set(position, item); + else { positions.put(itemKey, size()); add(item); } + } + } + + private static Map copyMap(Map values) { + Map result = new LinkedHashMap<>(); + if (values != null) values.forEach((key, value) -> result.put(key, copyValue(value))); + return result; + } + + private static Object copyValue(Object value) { + if (value instanceof Map map) { + Map copy = new LinkedHashMap<>(); + map.forEach((key, item) -> copy.put(key, copyValue(item))); + return copy; + } + if (value instanceof List list) { + List copy = new ArrayList<>(); + list.forEach(item -> copy.add(copyValue(item))); + return copy; + } + if (value instanceof Set set) { + Set copy = new LinkedHashSet<>(); + set.forEach(item -> copy.add(copyValue(item))); + return copy; + } + return value; + } + private void ensureMutable() { if (immutable) { throw new UnsupportedOperationException("A shared empty SmartList is immutable"); diff --git a/teaql-core/src/test/java/io/teaql/core/SmartListSemanticsTest.java b/teaql-core/src/test/java/io/teaql/core/SmartListSemanticsTest.java new file mode 100644 index 00000000..cf0dc6b7 --- /dev/null +++ b/teaql-core/src/test/java/io/teaql/core/SmartListSemanticsTest.java @@ -0,0 +1,153 @@ +package io.teaql.core; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.util.*; +import java.util.function.Function; +import org.junit.Test; +import static org.junit.Assert.*; + +public class SmartListSemanticsTest { + private static final ObjectMapper JSON = new ObjectMapper(); + private static JsonNode vectors() throws Exception { + try (var source = SmartListSemanticsTest.class.getResourceAsStream("/smart-list-semantics.json")) { + return JSON.readTree(source); + } + } + + private static void aggregate(SmartList list, String alias, Object value) { + AggregationItem item = new AggregationItem(); + item.addValue(new SimpleNamedExpression(alias), value); + AggregationResult result = new AggregationResult(); + result.setData(new ArrayList<>(List.of(item))); + list.addAggregationResult(null, result); + } + + @Test public void loadedUnknownZeroAndPageAreDistinct() throws Exception { + for (JsonNode state : vectors().get("states")) { + List data = JSON.convertValue(state.get("rows"), new com.fasterxml.jackson.core.type.TypeReference<>() {}); + SmartList list = state.get("loaded").asBoolean() ? new SmartList<>(data) : SmartList.unloaded(); + if (!state.get("total").isNull()) aggregate(list, TeaQLConstants.ROOT_LIST_PARAMETER_NAME, state.get("total").longValue()); + assertEquals(state.get("loaded").asBoolean(), list.isLoaded()); + assertEquals(data, list.getData()); + Long expected = state.get("total").isNull() ? null : state.get("total").longValue(); + assertEquals(expected, list.getExactTotalCount()); + assertEquals(expected == null ? data.size() : expected.longValue(), list.getTotalCountOrSize()); + assertEquals(expected, list.getExactTotalCount()); + } + assertTrue(SmartList.empty(SmartListTest.DummyEntity.class).isLoaded()); + assertFalse(SmartList.unloaded().map(Function.identity()).isLoaded()); + assertFalse(SmartList.unloaded().filter(value -> true).isLoaded()); + assertThrows(UnsupportedOperationException.class, () -> SmartList.unloaded().add(1)); + } + + @Test public void invalidAndWideCountsAreNeverTruncated() { + for (Object value : Arrays.asList(-1, 1.5, Double.NaN, Double.POSITIVE_INFINITY, "3", null, + new java.math.BigInteger("9223372036854775808"))) { + SmartList list = new SmartList<>(); + aggregate(list, TeaQLConstants.ROOT_LIST_PARAMETER_NAME, value); + assertThrows("invalid exact count: " + value, IllegalStateException.class, list::getExactTotalCount); + } + SmartList list = new SmartList<>(); + aggregate(list, TeaQLConstants.ROOT_LIST_PARAMETER_NAME, 4_294_967_296L); + assertEquals(Long.valueOf(4_294_967_296L), list.getExactTotalCount()); + assertEquals(4_294_967_296L, list.getTotalCountOrSize()); + } + + @Test public void scalarAliasesPreserveNullAndUnrelatedAliasesDoNotBecomeTotals() { + SmartList list = new SmartList<>(); + aggregate(list, "nullable", null); + aggregate(list, "unrelated", 15); + assertTrue(list.aggregationProperties().containsKey("nullable")); + assertNull(list.aggregationProperties().get("nullable")); + assertFalse(list.aggregationProperties().containsKey("missing")); + assertEquals(Map.of("unrelated", 15), list.aggregationNumberProperties()); + assertNull(list.getExactTotalCount()); + } + + @Test @SuppressWarnings("unchecked") public void mapIsolatesNestedMetadataAndRecordFacets() throws Exception { + SmartList source = new SmartList<>(List.of(1, 2, 3)); + aggregate(source, TeaQLConstants.ROOT_LIST_PARAMETER_NAME, 9); + aggregate(source, "nested", new LinkedHashMap<>(Map.of("values", new ArrayList<>(List.of(1, 2))))); + BigDecimal decimal = new BigDecimal(vectors().path("metadata").path("decimal").path("value").asText()); + aggregate(source, "decimal", decimal); + source.setSummary(Map.of("nested", Map.of("values", List.of(3)))); + source.addFacet("empty", new SmartList<>()); + source.addFacet("status", new SmartList<>(List.of(new LinkedHashMap<>(Map.of("status", "paid", "count", 3))))); + SmartList mapped = source.map(value -> value * 10); + assertEquals(List.of(10, 20, 30), mapped.getData()); + assertEquals(Long.valueOf(9), mapped.getExactTotalCount()); + assertEquals(source.aggregationProperties(), mapped.aggregationProperties()); + assertEquals(source.getSummary(), mapped.getSummary()); + assertEquals(decimal, mapped.aggregationProperties().get("decimal")); + ((List) ((Map) mapped.aggregationProperties().get("nested")).get("values")).add(99); + ((Map) mapped.getFacet("status").first()).put("count", 99); + mapped.removeFacet("empty"); + mapped.setSummary(Map.of("changed", true)); + assertEquals(List.of(1, 2), ((Map) source.aggregationProperties().get("nested")).get("values")); + assertEquals(3, ((Map) source.getFacet("status").first()).get("count")); + assertNotNull(source.getFacet("empty")); + assertFalse(source.getSummary().containsKey("changed")); + for (SmartList derived : List.of(source.filter(value -> value % 2 == 1), + source.filter(value -> true), source.flatMap(value -> List.of(value, value)))) { + assertTrue(derived.isLoaded()); assertNull(derived.getExactTotalCount()); + assertTrue(derived.getFacets().isEmpty()); assertTrue(derived.getSummary().isEmpty()); + assertTrue(derived.getAggregationResults().isEmpty()); + } + assertEquals(List.of(1, 3), source.filter(value -> value % 2 == 1).getData()); + assertEquals(List.of(1, 1, 2, 2, 3, 3), source.flatMap(value -> List.of(value, value)).getData()); + List nativeCopy = source.toList(Function.identity()); nativeCopy.set(0, 99); + assertEquals(Integer.valueOf(1), source.first()); + } + + @Test public void duplicateMergeOrderIndexAndGroupsFollowVectors() throws Exception { + JsonNode merge = vectors().path("merge"); + List target = new ArrayList<>(), incoming = new ArrayList<>(), expected = new ArrayList<>(); + merge.path("target").forEach(target::add); merge.path("incoming").forEach(incoming::add); + merge.path("expected").forEach(expected::add); + SmartList list = new SmartList<>(target); + aggregate(list, TeaQLConstants.ROOT_LIST_PARAMETER_NAME, 9); + list.mergeBy(incoming, row -> row.path("key").asText()); + assertEquals(expected, list.getData()); + assertEquals(4, list.identityMap(row -> row.path("key").asText()).get("a").path("value").asInt()); + assertEquals(List.of(expected.get(0), expected.get(1)), list.groupBy(row -> row.path("key").asText()).get("a")); + assertEquals(Long.valueOf(9), list.getExactTotalCount()); + assertNull(list.getOrNull(-1)); assertNull(list.getOrNull(list.size())); + assertThrows(IndexOutOfBoundsException.class, () -> list.set(-1, expected.get(0))); + assertNull(new SmartList<>().first()); assertNull(new SmartList<>().last()); + } + + @Test public void missingEntityIdIsOmittedAndFacetsCanBeTaken() { + var one = new SmartListTest.DummyEntity(1L); + var lastOne = new SmartListTest.DummyEntity(1L); + var noId = new SmartListTest.OtherEntity(); + SmartList list = new SmartList<>(Arrays.asList(one, noId, lastOne)); + assertEquals(Arrays.asList(1L, null, 1L), list.ids()); + assertEquals(3, list.versions().size()); + assertEquals(Map.of(1L, lastOne), list.mapById()); + list.addFacet("empty", new SmartList<>()); + assertNull(list.getFacet("absent")); assertNotNull(list.getFacet("empty")); + Map> taken = list.takeFacets(); + assertTrue(taken.containsKey("empty")); assertTrue(list.getFacets().isEmpty()); + } + + @Test public void projectionRecordIdentitySelectorsPreserveMixedIdKindsAndMissingValues() throws Exception { + JsonNode fixture = vectors().get("identity"); + List> data = JSON.convertValue(fixture.get("rows"), new com.fasterxml.jackson.core.type.TypeReference<>() {}); + SmartList> rows = new SmartList<>(data); + assertEquals(JSON.convertValue(fixture.get("ids"), List.class), rows.toList(row -> row.get("id"))); + assertEquals(JSON.convertValue(fixture.get("versions"), List.class), rows.toList(row -> row.get("version"))); + var index = rows.filter(row -> row.get("id") != null).identityMap(row -> row.get("id")); + assertEquals(4, index.size()); assertFalse(index.containsKey(null)); + var keys = List.of(0, 1, "1", ""); + for (int position = 0; position < keys.size(); position++) + assertSame(rows.get(fixture.get("indexedPositions").get(position).intValue()), index.get(keys.get(position))); + } + + @Test public void alignmentAddsNoPublicStateOrSummarySetter() throws Exception { + assertTrue(Arrays.stream(SmartList.class.getMethods()).noneMatch(method -> + method.getName().equals("setLoaded") || method.getName().equals("setSummary"))); + assertFalse(java.lang.reflect.Modifier.isPublic(SmartList.class.getDeclaredMethod("setSummary", Map.class).getModifiers())); + } +} diff --git a/teaql-core/src/test/java/io/teaql/core/SmartListTest.java b/teaql-core/src/test/java/io/teaql/core/SmartListTest.java index 3d1b5811..d0f571dd 100644 --- a/teaql-core/src/test/java/io/teaql/core/SmartListTest.java +++ b/teaql-core/src/test/java/io/teaql/core/SmartListTest.java @@ -150,11 +150,8 @@ public void testAggregationsAndTotalCount() { resNull.setData(Arrays.asList(itemNull)); list.addAggregationResult(createDummyContext(), resNull); - try { - list.aggregationProperties(); - } catch (NullPointerException e) { - // expected because stringObjectEntry.getValue().getClass() throws NPE - } + assertTrue(list.aggregationProperties().containsKey("nullProp")); + assertNull(list.aggregationProperties().get("nullProp")); } @Test(expected = IllegalStateException.class) @@ -169,21 +166,7 @@ public void testTotalCountException() { res.setData(Arrays.asList(item)); list.addAggregationResult(createDummyContext(), res); - // Will throw IllegalStateException since it expects a Number but it's a String... - // Wait, aggregationNumberProperties() filters by Number.class, so "not-a-number" is ignored. - // If it's ignored, numProps is empty, it returns size(). Let's force it by mocking or tricking it. - // Actually, if aggregationNumberProperties returns an object that is NOT a Number, the exception is thrown. - // But aggregationNumberProperties(Number.class) only puts it in the map IF it is assignable to Number. - // So it's impossible to have a String in aggregationNumberProperties! - // The code in SmartList: - // Map numberProps = aggregationNumberProperties(); - // Object count = numberProps.get(TeaQLConstants.ROOT_LIST_PARAMETER_NAME); - // if (count instanceof Number) return ... - // throw new IllegalStateException - // BUT aggregationNumberProperties() ensures all values are Number! - // So the exception might be dead code unless numberProps could contain a null or we mock it. - // I will just mock aggregationNumberProperties if I can, or ignore the exception test. - throw new IllegalStateException("Simulated"); + list.getExactTotalCount(); } @Test @@ -198,15 +181,8 @@ public void testTotalCountEmptyAgg() { res.setData(Arrays.asList(item)); list.addAggregationResult(createDummyContext(), res); - // numberProps does not contain ROOT_LIST_PARAMETER_NAME - // count is null, which is not instanceof Number - // Exception should be thrown because count == null - try { - list.getTotalCount(); - fail("Expected exception"); - } catch (NullPointerException e) { - // Expected since count is null and it calls count.getClass() - } + assertNull(list.getExactTotalCount()); + assertEquals(1, list.getTotalCountOrSize()); } @Test @@ -225,7 +201,7 @@ public void testFacets() { list.clearFacets(); assertTrue(list.getFacets().isEmpty()); - Map facetMap = new HashMap<>(); + Map> facetMap = new HashMap<>(); facetMap.put("f3", facet); list.setFacets(facetMap); assertEquals(1, list.getFacets().size()); @@ -260,21 +236,17 @@ public void testAggregationPropertiesWithClassEmpty() { @Test public void testTotalCountExceptionBranch() { - SmartList list = new SmartList() { - @Override - public Map aggregationNumberProperties() { - Map map = new HashMap<>(); - map.put(TeaQLConstants.ROOT_LIST_PARAMETER_NAME, "not-a-number"); - return map; - } - }; - // Add dummy result to pass ObjectUtil.isEmpty(aggregationResults) - list.addAggregationResult(createDummyContext(), new AggregationResult()); + SmartList list = new SmartList<>(); + AggregationItem item = new AggregationItem(); + item.addValue(new SimpleNamedExpression(TeaQLConstants.ROOT_LIST_PARAMETER_NAME), 1.5); + AggregationResult result = new AggregationResult(); + result.setData(List.of(item)); + list.addAggregationResult(createDummyContext(), result); try { list.getTotalCount(); fail("Expected exception"); } catch (IllegalStateException e) { - assertTrue(e.getMessage().contains("is now a String")); + assertTrue(e.getMessage().contains("exact non-negative long")); } } } diff --git a/teaql-core/src/test/resources/smart-list-semantics.json b/teaql-core/src/test/resources/smart-list-semantics.json new file mode 100644 index 00000000..1d30be8d --- /dev/null +++ b/teaql-core/src/test/resources/smart-list-semantics.json @@ -0,0 +1,34 @@ +{ + "schema": "teaql.smart-list-semantics/v1", + "states": [ + {"name": "unloaded", "loaded": false, "rows": [], "total": null}, + {"name": "loaded-empty-unknown", "loaded": true, "rows": [], "total": null}, + {"name": "loaded-empty-zero", "loaded": true, "rows": [], "total": 0}, + {"name": "page", "loaded": true, "rows": [1, 2], "total": 5} + ], + "metadata": { + "aggregations": {"count": 5, "nullable": null, "nested": {"values": [1, 2]}}, + "summary": {"label": "orders", "active": true, "nested": {"values": [3]}}, + "facets": {"empty": [], "status": [{"status": "paid", "count": 3}]}, + "decimal": {"type": "decimal", "value": "9007199254740993.125"} + }, + "transform": { + "rows": [1, 2, 3], "total": 9, + "mapTimesTen": [10, 20, 30], "filterOdd": [1, 3], "flatMapTwice": [1, 1, 2, 2, 3, 3], + "safeMissingIndexes": [-1, 3, 100] + }, + "merge": { + "target": [{"key": "a", "value": 1}, {"key": "a", "value": 2}, {"key": "b", "value": 3}], + "incoming": [{"key": "a", "value": 4}, {"key": "c", "value": 5}, {"key": "c", "value": 6}, {"key": "d", "value": 7}], + "expected": [{"key": "a", "value": 1}, {"key": "a", "value": 4}, {"key": "b", "value": 3}, {"key": "c", "value": 6}, {"key": "d", "value": 7}] + }, + "identity": { + "rows": [{"id": 0, "version": 1}, {"id": null, "version": 2}, {"id": 1, "version": 3}, {"id": 1, "version": 4}, {"id": "1", "version": 5}, {"id": "", "version": 6}], + "ids": [0, null, 1, 1, "1", ""], "versions": [1, 2, 3, 4, 5, 6], "indexedPositions": [0, 3, 4, 5] + }, + "sqlite": { + "rows": [{"id": 1, "status": "paid", "amount": "10.25"}, {"id": 2, "status": "open", "amount": "2.50"}, {"id": 3, "status": "paid", "amount": "7.25"}], + "total": 3, "pageLimit": 1, "pageOffset": 1, "pageIds": [2], "emptyStatus": "missing", + "sumAmount": "20.00", "statusCounts": {"paid": 2, "open": 1} + } +} diff --git a/teaql-jackson/src/main/java/io/teaql/jackson/BaseEntityJsonSerializer.java b/teaql-jackson/src/main/java/io/teaql/jackson/BaseEntityJsonSerializer.java index 72900ac4..3c47b382 100644 --- a/teaql-jackson/src/main/java/io/teaql/jackson/BaseEntityJsonSerializer.java +++ b/teaql-jackson/src/main/java/io/teaql/jackson/BaseEntityJsonSerializer.java @@ -47,8 +47,14 @@ public void serialize(BaseEntity value, JsonGenerator gen, SerializerProvider se serializers.defaultSerializeField(member, value.__internalGet(member), gen); } for (String relation : layout.relationNames()) { - if (layout.findIndex(relation) != null || !state.isLoaded(relation)) continue; - serializers.defaultSerializeField(relation, value.__internalGet(relation), gen); + if (layout.findIndex(relation) != null) continue; + Object children = value.__internalGet(relation); + // Pending business edits are present without certifying that + // this relation was queried. Merely reading never loads it. + boolean pending = children instanceof io.teaql.core.SmartList list + && list.stream().anyMatch(child -> child instanceof io.teaql.core.Entity entity && entity.needPersist()); + if (!state.isLoaded(relation) && !pending) continue; + serializers.defaultSerializeField(relation, children, gen); } } for (Map.Entry entry : value.getAdditionalInfo().entrySet()) { diff --git a/teaql-jackson/src/main/java/io/teaql/jackson/SmartListAsListSerializer.java b/teaql-jackson/src/main/java/io/teaql/jackson/SmartListAsListSerializer.java index e0e95e78..63af2189 100644 --- a/teaql-jackson/src/main/java/io/teaql/jackson/SmartListAsListSerializer.java +++ b/teaql-jackson/src/main/java/io/teaql/jackson/SmartListAsListSerializer.java @@ -19,6 +19,8 @@ protected SmartListAsListSerializer(Class type) { public void serialize(SmartList value, JsonGenerator gen, SerializerProvider provider) throws IOException { List data = value.getData(); - gen.writePOJO(data); + // Reuse the active provider's cycle path and scalar serializers even + // when the generator is a TokenBuffer (ObjectMapper.valueToTree). + provider.defaultSerializeValue(data, gen); } } diff --git a/teaql-jackson/src/test/java/io/teaql/jackson/BaseEntitySerializationTest.java b/teaql-jackson/src/test/java/io/teaql/jackson/BaseEntitySerializationTest.java index c9fccc24..57e7d9dc 100644 --- a/teaql-jackson/src/test/java/io/teaql/jackson/BaseEntitySerializationTest.java +++ b/teaql-jackson/src/test/java/io/teaql/jackson/BaseEntitySerializationTest.java @@ -79,6 +79,33 @@ static final class JsonGraphEntity extends BaseEntity { } } + @Test public void pendingLocalChildrenSerializeWithoutLoadingAnUnqueriedRelation() throws Exception { + var parent = new NamedRelationsEntity(); + parent.childList = io.teaql.core.SmartList.unloaded().map(child -> child); + var child = new JsonGraphEntity(); child.__internalSet("name", "local child"); + parent.childList.add(child); + var state = parent.__internalLoadState(); + var mapper = new ObjectMapper().registerModule(TeaQLModule.INSTANCE); + assertEquals("local child", mapper.valueToTree(parent).get("childList").get(0).get("name").asText()); + assertFalse(parent.childList.isLoaded()); assertFalse(parent.isPropertyLoaded("childList")); + assertTrue(state == parent.__internalLoadState()); assertFalse(parent.__internalHasMutationLedger()); + child.set$status(io.teaql.core.EntityStatus.PERSISTED); + assertFalse(mapper.valueToTree(parent).has("childList")); + } + + @Test public void smartListBackReferencesKeepTheActiveSerializationPath() throws Exception { + var parent = new NamedRelationsEntity(); parent.updateId(7L); + parent.childList = io.teaql.core.SmartList.unloaded().map(child -> child); + var child = new JsonGraphEntity(); child.updateId(9L); child.__internalSet("child", parent); + parent.childList.add(child); + var mapper = new ObjectMapper().registerModule(TeaQLModule.INSTANCE); + var tree = mapper.valueToTree(parent); + var backReference = tree.get("childList").get(0).get("child"); + assertEquals(7L, backReference.get("id").asLong()); assertFalse(backReference.has("childList")); + assertEquals(tree.toString(), mapper.writeValueAsString(parent)); + assertFalse(parent.childList.isLoaded()); assertFalse(parent.isPropertyLoaded("childList")); + } + @Test public void nestedGraphsKeepThreeNamespacesAndLiteralPayloadWithoutInternalMetadata() throws Exception { var parent = new JsonGraphEntity(); parent.__internalSet("id",1L); parent.__internalSet("version",1L); parent.__internalSet("name","parent"); var child = new JsonGraphEntity(); child.__internalSet("id",2L); child.__internalSet("version",1L); child.__internalSet("name","native"); diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultQueryResult.java b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultQueryResult.java index 82698685..7963fa48 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultQueryResult.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultQueryResult.java @@ -3,21 +3,22 @@ import io.teaql.core.AggregationResult; import io.teaql.core.QueryResult; import io.teaql.core.SmartList; +import io.teaql.core.Entity; public class DefaultQueryResult implements QueryResult { - private final SmartList result; + private final SmartList result; private final AggregationResult aggregationResult; private final java.util.List statements; - public DefaultQueryResult(SmartList result) { + public DefaultQueryResult(SmartList result) { this(result, null); } - public DefaultQueryResult(SmartList result, AggregationResult aggregationResult) { + public DefaultQueryResult(SmartList result, AggregationResult aggregationResult) { this(result, aggregationResult, java.util.List.of()); } - public DefaultQueryResult(SmartList result, AggregationResult aggregationResult, + public DefaultQueryResult(SmartList result, AggregationResult aggregationResult, java.util.List statements) { this.result = result; this.aggregationResult = aggregationResult; @@ -26,7 +27,7 @@ public DefaultQueryResult(SmartList result, AggregationResult aggregationResu @Override public java.util.List statements() { return statements; } - public SmartList getResult() { + public SmartList getResult() { return result; } diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java index 88d60e55..e2c9ad90 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java @@ -214,6 +214,9 @@ public SmartList executeForList(UserContext context, Searc // Freeze their result only after that hook, while the intent header was // captured beforehand and cannot be overwritten by the policy. request = new QueryRequestSnapshot<>(request, requestScope); + if (request.getAggregations() != null && !request.getAggregations().getDimensions().isEmpty()) { + throw new TeaQLRuntimeException("Grouped projections require the aggregation result boundary"); + } QueryOperation countOperation = requestedListCount(request); SearchRequest countRequest = null; String countAlias = null; @@ -246,18 +249,16 @@ public SmartList executeForList(UserContext context, Searc } } - /** A declared ungrouped count accompanies rows through the normal list entry. - * Grouped/mixed aggregate requests retain their existing aggregate semantics. */ + /** A declared count accompanies entity rows or independent scalar aliases. */ private static QueryOperation requestedListCount(SearchRequest request) { QueryOperation operation = request.pageCountOperation(); if (operation == null || !(request instanceof BaseRequest)) return null; var aggregations = request.getAggregations(); - if (aggregations == null || !aggregations.getSimpleDimensions().isEmpty() - || !aggregations.getComplexDimensions().isEmpty() || aggregations.getAggregates().size() != 1) return null; + if (aggregations == null || !aggregations.getDimensions().isEmpty()) return null; String alias = operation.alias() == null ? TeaQLConstants.ROOT_LIST_PARAMETER_NAME : operation.alias(); - var aggregate = aggregations.getAggregates().get(0); - return alias.equals(aggregate.name()) && aggregate.getExpression() instanceof AggrExpression expression - && expression.getOperator() == AggrFunction.COUNT ? operation : null; + return aggregations.getAggregates().stream().anyMatch(aggregate -> alias.equals(aggregate.name()) + && aggregate.getExpression() instanceof AggrExpression expression + && expression.getOperator() == AggrFunction.COUNT) ? operation : null; } /** Executes a business-facing streaming query after the same policy gate as list queries. */ @@ -297,6 +298,11 @@ public Stream internalExecuteForStream( @SuppressWarnings("unchecked") private Stream executeForStreamResolved( UserContext context, SearchRequest request, QueryIntent intent) { + var aggregations = request.getAggregations(); + if (request.hasSimpleAgg() || (aggregations != null && !aggregations.getDimensions().isEmpty())) { + throw new TeaQLRuntimeException( + "Entity streams require entity rows; use an aggregation or projection result for aggregate/grouped queries"); + } EntityDescriptor descriptor = metadata.resolveEntityDescriptor(request.getTypeName()); String route = descriptor != null ? descriptor.getDataService() : null; if (route == null || route.isEmpty()) { @@ -403,8 +409,13 @@ private SmartList executeForListResolved( providerScope.failure(error); throw error; } - if (queryResult instanceof DefaultQueryResult) { - return (SmartList) ((DefaultQueryResult) queryResult).getResult(); + if (queryResult instanceof DefaultQueryResult result) { + SmartList rows = (SmartList) result.getResult(); + if (result.getAggregationResult() != null) { + if (rows.isSharedEmpty()) rows = new SmartList<>(); + rows.addAggregationResult(context, result.getAggregationResult()); + } + return rows; } throw new TeaQLRuntimeException( "Unsupported QueryResult type: " + queryResult.getClass().getName()); diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/RequestedListCountTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/RequestedListCountTest.java index 2cf4eacb..133155f2 100644 --- a/teaql-runtime/src/test/java/io/teaql/runtime/RequestedListCountTest.java +++ b/teaql-runtime/src/test/java/io/teaql/runtime/RequestedListCountTest.java @@ -54,11 +54,47 @@ private BaseRequest request() { var runtime = TeaQLRuntime.builder().metadata(new TeaQLRuntimeTest.DummyMetaFactory()).dataService("dummy", executor).build(); var source = request(); source.getAggregations().getSimpleDimensions().add(new SimpleNamedExpression("name", new PropertyReference("name"))); - runtime.executeForList(new DefaultUserContext(runtime), source); + runtime.aggregation(new DefaultUserContext(runtime), source); assertEquals(1, executor.requests.size()); assertTrue(executor.requests.get(0).hasSimpleAgg()); assertEquals(1, executor.requests.get(0).getAggregations().getSimpleDimensions().size()); } + @Test public void scalarAliasesAndNullRemainMetadataAlongsideOptionalExactCount() { + for (boolean counted : new boolean[]{false, true}) for (Object value : new Object[]{600L, null}) { + var executor = new TeaQLRuntimeTest.PageQueryExecutor() { + @Override public QueryResult query(UserContext context, QueryRequest query) { + SearchRequest source = ((DefaultQueryRequest) query).getSearchRequest(); + requests.add(source); + var values = new java.util.HashMap(); + for (var aggregate : source.getAggregations().getAggregates()) + values.put(new SimpleNamedExpression(aggregate.name()), + ((AggrExpression) aggregate.getExpression()).getOperator() == AggrFunction.COUNT ? 3L : value); + var item = new AggregationItem(); item.setValues(values); + var result = new AggregationResult(); result.setData(List.of(item)); + return new DefaultQueryResult(new SmartList<>(), result); + } + }; + var runtime = TeaQLRuntime.builder().metadata(new TeaQLRuntimeTest.DummyMetaFactory()).dataService("dummy", executor).build(); + var source = request(); + if (!counted) source.getAggregations().getAggregates().clear(); + source.sum("sum", "amount"); + var rows = runtime.executeForList(new DefaultUserContext(runtime), source); + assertTrue(rows.isEmpty()); assertTrue(rows.aggregationProperties().containsKey("sum")); + assertEquals(value, rows.aggregationProperties().get("sum")); + assertEquals(counted ? Long.valueOf(3) : null, rows.getExactTotalCount()); + assertEquals(counted ? 2 : 1, executor.requests.size()); + } + } + + @Test public void groupedProjectionIsRejectedAtTheEntityListBoundary() { + var executor = new TeaQLRuntimeTest.PageQueryExecutor(); + var runtime = TeaQLRuntime.builder().metadata(new TeaQLRuntimeTest.DummyMetaFactory()).dataService("dummy", executor).build(); + var source = request(); + source.getAggregations().getSimpleDimensions().add(new SimpleNamedExpression("name", new PropertyReference("name"))); + assertThrows(TeaQLRuntimeException.class, () -> runtime.executeForList(new DefaultUserContext(runtime), source)); + assertTrue(executor.requests.isEmpty()); + } + @Test public void noSeparatePageExecutionEntryRemainsInCoreOrRuntime() { for (Class type : List.of(ExecutableRequest.class, UserContext.class, DefaultUserContext.class, TeaQLRuntime.class)) { assertFalse(type.getName(), java.util.Arrays.stream(type.getMethods()) diff --git a/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLDataService.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLDataService.java index e27bc6de..88712dd9 100644 --- a/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLDataService.java +++ b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLDataService.java @@ -87,7 +87,7 @@ public QueryResult query(UserContext context, QueryRequest request) { repository.doAggregateInternal(context, (SearchRequest) searchRequest, intent); return new DefaultQueryResult(new SmartList<>(), aggregation, statements); } - SmartList result = repository.loadInternal(context, (SearchRequest) searchRequest, intent); + SmartList result = repository.loadInternal(context, (SearchRequest) searchRequest, intent); if (searchRequest.enhanceRelations() != null && !searchRequest.enhanceRelations().isEmpty()) { enhanceRelations(context, (SmartList) result, searchRequest, intent); @@ -99,11 +99,11 @@ public QueryResult query(UserContext context, QueryRequest request) { return new DefaultQueryResult((SmartList) result, null, statements); } - private void attachDynamicFields(UserContext context, SmartList results, SearchRequest request) { + private void attachDynamicFields(UserContext context, SmartList results, SearchRequest request) { attachDynamicFields(context, results, request, new java.util.HashMap<>()); } - private void attachDynamicFields(UserContext context, SmartList results, SearchRequest request, + private void attachDynamicFields(UserContext context, SmartList results, SearchRequest request, Map, LoadState>> shapes) { var selection = request.getDynamicFieldSelection(); if (selection == null || results.isEmpty()) return; @@ -194,8 +194,12 @@ private void attachDynamicAggregations( AggregationResult aggregation = aggregateRepository.doAggregateInternal(userContext, request, intent == null ? null : intent.copy()); if (attribute.isSingleNumber()) { + var expressions = aggregateRequest.getAggregations().getAggregates(); + boolean count = expressions.isEmpty() || expressions.size() == 1 + && expressions.get(0).getExpression() instanceof AggrExpression expression + && expression.getOperator() == AggrFunction.COUNT; for (Entity parent : parentsById.values()) { - parent.addDynamicProperty(attribute.getName(), 0); + parent.addDynamicProperty(attribute.getName(), count ? 0 : null); } aggregation .toSimpleMap() diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java index 060acff5..922cc500 100644 --- a/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/DerivedQueryTraceSqliteTest.java @@ -590,7 +590,7 @@ private void verifyTwoLiveSqliteQueries(boolean sharedTemplate) throws Exception var rows = results.get(i); var label = i == 0 ? "alpha" : "beta"; assertEquals(1, rows.size()); assertEquals(Long.valueOf(i == 0 ? 100 : 200), rows.get(0).getId()); - SmartList children = rows.get(0).getProperty("lines"); + SmartList children = rows.get(0).getProperty("lines"); assertEquals(i == 0 ? 2 : 1, children.size()); assertEquals(i == 0 ? List.of(101L, 102L) : List.of(201L), children.stream().map(Entity::getId).sorted().toList()); @@ -689,7 +689,7 @@ private void verifyFilteredReferenceMembership(boolean nested, boolean logging, TraceDocument owner = nested ? (TraceDocument) result.getResult().get(0).getProperty("document") : (TraceDocument) result.getResult().get(0); - SmartList loaded = owner.getProperty("lines"); + SmartList loaded = owner.getProperty("lines"); assertNotNull(loaded); assertEquals(2, loaded.size()); assertEquals(1, ((Number) owner.getDynamicProperty("openLineCount")).intValue()); diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/LikeIntentPrivacySqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/LikeIntentPrivacySqliteTest.java index 457a610a..65e5960d 100644 --- a/teaql-sqlite/src/test/java/io/teaql/sqlite/LikeIntentPrivacySqliteTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/LikeIntentPrivacySqliteTest.java @@ -121,7 +121,7 @@ private Request request(Class type, Supplier factory for (String field : List.of("id", "version", "name", "state")) request.selectProperty(field); request.top(10); return request; } - SmartList run(Request request) { return context.getRuntime().executeForList(context, request); } + SmartList run(Request request) { return context.getRuntime().executeForList(context, request); } void clear() { binds.clear(); safe.clear(); policies.clear(); output.reset(); } } private void assertProjection(Fixture f, Request request, String operand, boolean privateOperand, int count) { diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/TypedIntentPrivacySqliteTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/TypedIntentPrivacySqliteTest.java index a0f542c2..5cab4958 100644 --- a/teaql-sqlite/src/test/java/io/teaql/sqlite/TypedIntentPrivacySqliteTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/TypedIntentPrivacySqliteTest.java @@ -113,7 +113,7 @@ private void assertBinds(LikeIntentPrivacySqliteTest.Fixture f, List ope var root = f.documents().intent(intent(values)); root.enhanceRelation("lines", child); var rows = f.run(root); assertEquals(2, rows.size()); - var members = (SmartList) rows.get(0).getProperty("lines"); + var members = (SmartList) rows.get(0).getProperty("lines"); assertEquals(1, members.size()); assertEquals(Long.valueOf(11), members.get(0).getId()); assertEquals(Long.valueOf(1), ((Entity) members.get(0).getProperty("document")).getId()); var absent = (SmartList) rows.get(1).getProperty("lines");