diff --git a/README.md b/README.md index f2ed1c7..7d0a1ea 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ This project was written as a solution to a Java developer job interview quiz. T - Define shapes interactively or from a file: **circle**, **triangle**, **donut** - Query all shapes that contain a given point, along with each shape's surface area and the total combined area -- Point queries run in **parallel** for good performance with large numbers of shapes +- Point queries are backed by an in-memory **spatial grid index** (bounding-box broad phase) and run in **parallel** for good performance with large numbers of shapes - Shapes are persisted in an **ObjectDB** database - Additional commands: `list`, `clear`, `help`, `exit` - Meaningful error messages for unexpected input; execution continues after an error @@ -134,7 +134,7 @@ Shapes can also be loaded from a file with `-f ` (see `shapesInput.txt - **Java 25** — language features and Java streams - **Maven** — build tool and dependency management -- **Java Streams** — parallel processing for point queries +- **Java Streams** — parallel point query processing over index candidates - **Guice** — dependency injection - **ObjectDB + JPA** — shape persistence - **Args4j** — command line argument parsing diff --git a/src/main/java/com/progys/interview/quiz/commands/PointCommand.java b/src/main/java/com/progys/interview/quiz/commands/PointCommand.java index 613c66d..b9f70e5 100644 --- a/src/main/java/com/progys/interview/quiz/commands/PointCommand.java +++ b/src/main/java/com/progys/interview/quiz/commands/PointCommand.java @@ -27,7 +27,7 @@ public class PointCommand extends AbstractCommand { @Override public void process() { - List containing = persistence.getAll().parallelStream() + List containing = persistence.queryContaining(point).parallelStream() .filter(stored -> stored.shape().inShape(point)) .toList(); printShapes(containing); diff --git a/src/main/java/com/progys/interview/quiz/model/BoundingBox.java b/src/main/java/com/progys/interview/quiz/model/BoundingBox.java new file mode 100644 index 0000000..7574b1d --- /dev/null +++ b/src/main/java/com/progys/interview/quiz/model/BoundingBox.java @@ -0,0 +1,12 @@ +package com.progys.interview.quiz.model; + +/** + * Axis-aligned bounding box of a shape. + * + * @author progys + */ +public record BoundingBox(double minX, double maxX, double minY, double maxY) { + public boolean contains(Point point) { + return point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY; + } +} diff --git a/src/main/java/com/progys/interview/quiz/model/Circle.java b/src/main/java/com/progys/interview/quiz/model/Circle.java index 21772a5..c278997 100644 --- a/src/main/java/com/progys/interview/quiz/model/Circle.java +++ b/src/main/java/com/progys/interview/quiz/model/Circle.java @@ -40,6 +40,12 @@ public boolean inShape(Point point) { return pow(point.x - center.x, 2) + pow(point.y - center.y, 2) < pow(radius, 2); } + @Override + public BoundingBox getBounds() { + return new BoundingBox(center.x - radius, center.x + radius, center.y - radius, + center.y + radius); + } + @Override public String toString() { return String.format("circle with centre at (%s, %s) and radius %s", center.x, center.y, diff --git a/src/main/java/com/progys/interview/quiz/model/Donut.java b/src/main/java/com/progys/interview/quiz/model/Donut.java index df45aad..b1cb471 100644 --- a/src/main/java/com/progys/interview/quiz/model/Donut.java +++ b/src/main/java/com/progys/interview/quiz/model/Donut.java @@ -52,6 +52,11 @@ public boolean inShape(Point point) { return outerCircle.inShape(point) && !innerCircle.inShape(point); } + @Override + public BoundingBox getBounds() { + return outerCircle.getBounds(); + } + @Override public String toString() { return String.format( diff --git a/src/main/java/com/progys/interview/quiz/model/Shape.java b/src/main/java/com/progys/interview/quiz/model/Shape.java index c631e2f..48aee9a 100644 --- a/src/main/java/com/progys/interview/quiz/model/Shape.java +++ b/src/main/java/com/progys/interview/quiz/model/Shape.java @@ -9,4 +9,6 @@ public sealed interface Shape permits Circle, Triangle, Donut { double getArea(); boolean inShape(Point point); + + BoundingBox getBounds(); } diff --git a/src/main/java/com/progys/interview/quiz/model/Triangle.java b/src/main/java/com/progys/interview/quiz/model/Triangle.java index 7110c1b..7075531 100644 --- a/src/main/java/com/progys/interview/quiz/model/Triangle.java +++ b/src/main/java/com/progys/interview/quiz/model/Triangle.java @@ -3,6 +3,8 @@ import com.google.common.base.Preconditions; import static java.lang.StrictMath.abs; +import static java.lang.StrictMath.max; +import static java.lang.StrictMath.min; /** * Defines a triangle. @@ -51,6 +53,13 @@ public boolean inShape(Point point) { return s > 0 && t > 0 && (s + t) < 1; } + @Override + public BoundingBox getBounds() { + return new BoundingBox( + min(min(v0.x, v1.x), v2.x), max(max(v0.x, v1.x), v2.x), + min(min(v0.y, v1.y), v2.y), max(max(v0.y, v1.y), v2.y)); + } + @Override public String toString() { return String.format("triangle at v0=(%s, %s), v1=(%s, %s), v2=(%s, %s)", v0.x, v0.y, v1.x, diff --git a/src/main/java/com/progys/interview/quiz/persistence/ObjectStore.java b/src/main/java/com/progys/interview/quiz/persistence/ObjectStore.java index 73061fe..ed30cde 100644 --- a/src/main/java/com/progys/interview/quiz/persistence/ObjectStore.java +++ b/src/main/java/com/progys/interview/quiz/persistence/ObjectStore.java @@ -2,6 +2,7 @@ import com.google.inject.Inject; import com.google.inject.Singleton; +import com.progys.interview.quiz.model.Point; import com.progys.interview.quiz.model.Shape; import javax.persistence.EntityManager; @@ -29,12 +30,15 @@ public class ObjectStore implements Store { private final EntityManagerFactory entityManagerFactory; private final EntityManager manager; private final List shapes; + private final ShapeIndex index; @Inject ObjectStore(EntityManagerFactory entityManagerFactory) { this.entityManagerFactory = entityManagerFactory; this.manager = entityManagerFactory.createEntityManager(); this.shapes = new ArrayList<>(loadShapesFromDatabase()); + this.index = new ShapeIndex(); + shapes.forEach(index::put); } @Override @@ -48,6 +52,7 @@ public StoredShape put(Shape shape) { transaction.commit(); StoredShape stored = new StoredShape(entity.getId(), shape); shapes.add(stored); + index.put(stored); return stored; } catch (RuntimeException e) { if (transaction.isActive()) { @@ -66,6 +71,7 @@ public void clear() { manager.createQuery("delete from ShapeEntity").executeUpdate(); transaction.commit(); shapes.clear(); + index.clear(); } catch (RuntimeException e) { if (transaction.isActive()) { transaction.rollback(); @@ -80,6 +86,11 @@ public Collection getAll() { return Collections.unmodifiableList(shapes); } + @Override + public Collection queryContaining(Point point) { + return index.query(point); + } + private List loadShapesFromDatabase() { TypedQuery query = manager.createQuery( "SELECT e FROM " + ShapeEntity.class.getName() + " e", ShapeEntity.class); diff --git a/src/main/java/com/progys/interview/quiz/persistence/ShapeIndex.java b/src/main/java/com/progys/interview/quiz/persistence/ShapeIndex.java new file mode 100644 index 0000000..361d544 --- /dev/null +++ b/src/main/java/com/progys/interview/quiz/persistence/ShapeIndex.java @@ -0,0 +1,81 @@ +package com.progys.interview.quiz.persistence; + +import com.progys.interview.quiz.model.BoundingBox; +import com.progys.interview.quiz.model.Point; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * In-memory uniform-grid index over shape bounding boxes. A point query only examines shapes + * whose bounding box overlaps the grid cell containing the query point, instead of scanning + * every stored shape. Shapes whose bounding box spans more than {@link #MAX_OVERLAPPED_CELLS} + * cells are kept in an overflow list checked by every query, which bounds the index memory + * footprint for very large shapes. + * + * @author progys + */ +public final class ShapeIndex { + private static final double DEFAULT_CELL_SIZE = 10.0; + private static final int MAX_OVERLAPPED_CELLS = 64; + + private final double cellSize; + private final Map> cells = new HashMap<>(); + private final List largeShapes = new ArrayList<>(); + + public ShapeIndex() { + this(DEFAULT_CELL_SIZE); + } + + public ShapeIndex(double cellSize) { + this.cellSize = cellSize; + } + + public void put(StoredShape stored) { + BoundingBox bounds = stored.shape().getBounds(); + long minX = cell(bounds.minX()); + long maxX = cell(bounds.maxX()); + long minY = cell(bounds.minY()); + long maxY = cell(bounds.maxY()); + + long width = maxX - minX + 1; + long height = maxY - minY + 1; + if (width > MAX_OVERLAPPED_CELLS || height > MAX_OVERLAPPED_CELLS + || width * height > MAX_OVERLAPPED_CELLS) { + largeShapes.add(stored); + return; + } + for (long x = minX; x <= maxX; x++) { + for (long y = minY; y <= maxY; y++) { + cells.computeIfAbsent(new Cell(x, y), key -> new ArrayList<>()).add(stored); + } + } + } + + public Collection query(Point point) { + List candidates = + cells.getOrDefault(new Cell(cell(point.x), cell(point.y)), List.of()); + if (largeShapes.isEmpty()) { + return candidates; + } + List result = new ArrayList<>(candidates.size() + largeShapes.size()); + result.addAll(candidates); + result.addAll(largeShapes); + return result; + } + + public void clear() { + cells.clear(); + largeShapes.clear(); + } + + private long cell(double coordinate) { + return (long) Math.floor(coordinate / cellSize); + } + + private record Cell(long x, long y) { + } +} diff --git a/src/main/java/com/progys/interview/quiz/persistence/Store.java b/src/main/java/com/progys/interview/quiz/persistence/Store.java index 337e606..42dc68e 100644 --- a/src/main/java/com/progys/interview/quiz/persistence/Store.java +++ b/src/main/java/com/progys/interview/quiz/persistence/Store.java @@ -1,5 +1,6 @@ package com.progys.interview.quiz.persistence; +import com.progys.interview.quiz.model.Point; import com.progys.interview.quiz.model.Shape; import java.util.Collection; @@ -14,5 +15,7 @@ public interface Store { Collection getAll(); + Collection queryContaining(Point point); + void close(); } diff --git a/src/test/java/com/progys/interview/quiz/commands/PointCommandTest.java b/src/test/java/com/progys/interview/quiz/commands/PointCommandTest.java index 9f176bc..935cc89 100644 --- a/src/test/java/com/progys/interview/quiz/commands/PointCommandTest.java +++ b/src/test/java/com/progys/interview/quiz/commands/PointCommandTest.java @@ -12,6 +12,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; class PointCommandTest { @@ -22,7 +23,8 @@ void printsContainingShapesInEncounterOrder() { StoredShape insideSmall = new StoredShape(1L, new Circle(new Point(0, 0), 1)); StoredShape outside = new StoredShape(2L, new Circle(new Point(100, 100), 1)); StoredShape insideBig = new StoredShape(3L, new Circle(new Point(0, 0), 2)); - when(mockStore.getAll()).thenReturn(List.of(insideSmall, outside, insideBig)); + when(mockStore.queryContaining(any(Point.class))).thenReturn( + List.of(insideSmall, outside, insideBig)); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); PointCommand pointCommand = @@ -41,7 +43,7 @@ void printsContainingShapesInEncounterOrder() { void printsNoShapesFoundWhenNothingContainsPoint() { Store mockStore = Mockito.mock(Store.class); StoredShape outside = new StoredShape(1L, new Circle(new Point(100, 100), 1)); - when(mockStore.getAll()).thenReturn(List.of(outside)); + when(mockStore.queryContaining(any(Point.class))).thenReturn(List.of(outside)); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); PointCommand pointCommand = diff --git a/src/test/java/com/progys/interview/quiz/persistence/ObjectStoreTest.java b/src/test/java/com/progys/interview/quiz/persistence/ObjectStoreTest.java index e853723..027146e 100644 --- a/src/test/java/com/progys/interview/quiz/persistence/ObjectStoreTest.java +++ b/src/test/java/com/progys/interview/quiz/persistence/ObjectStoreTest.java @@ -74,6 +74,16 @@ void clearEmptiesStore() { assertThat(store.getAll()).isEmpty(); } + @Test + void queryContainingReturnsOnlyShapesNearThePoint() { + StoredShape circle = store.put(new Circle(new Point(0, 0), 1)); + store.put(new Triangle(new Point(100, 100), new Point(101, 100), new Point(100, 101))); + + Collection candidates = store.queryContaining(new Point(0.5, 0.5)); + + assertThat(candidates).containsExactly(circle); + } + @Test void loadsPersistedShapesFromDatabaseOnStartup() { store.put(new Triangle(new Point(0, 0), new Point(1, 0), new Point(0, 1))); diff --git a/src/test/java/com/progys/interview/quiz/persistence/ShapeIndexTest.java b/src/test/java/com/progys/interview/quiz/persistence/ShapeIndexTest.java new file mode 100644 index 0000000..e77fc85 --- /dev/null +++ b/src/test/java/com/progys/interview/quiz/persistence/ShapeIndexTest.java @@ -0,0 +1,88 @@ +package com.progys.interview.quiz.persistence; + +import com.progys.interview.quiz.model.Circle; +import com.progys.interview.quiz.model.Donut; +import com.progys.interview.quiz.model.Point; +import com.progys.interview.quiz.model.Triangle; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ShapeIndexTest { + private static final StoredShape SMALL_CIRCLE = + new StoredShape(1L, new Circle(new Point(0, 0), 1)); + private static final StoredShape BIG_CIRCLE = + new StoredShape(2L, new Circle(new Point(1000, 1000), 500)); + + @Test + void findsShapeFromQueryPointInsideItsBoundingBox() { + ShapeIndex index = new ShapeIndex(); + index.put(SMALL_CIRCLE); + + assertThat(index.query(new Point(0.5, 0.5))).containsExactly(SMALL_CIRCLE); + } + + @Test + void findsShapeSpanningMultipleCellsFromAnyOverlappedCell() { + ShapeIndex index = new ShapeIndex(1.0); + StoredShape circle = new StoredShape(3L, new Circle(new Point(0, 0), 2)); + index.put(circle); + + assertThat(index.query(new Point(-1.5, -1.5))).containsExactly(circle); + assertThat(index.query(new Point(1.5, 1.5))).containsExactly(circle); + } + + @Test + void keepsLargeShapesInOverflowListFoundFromAnyPoint() { + ShapeIndex index = new ShapeIndex(1.0); + index.put(BIG_CIRCLE); + + assertThat(index.query(new Point(900, 900))).containsExactly(BIG_CIRCLE); + assertThat(index.query(new Point(1100, 1100))).containsExactly(BIG_CIRCLE); + } + + @Test + void combinesCellCandidatesAndLargeShapes() { + ShapeIndex index = new ShapeIndex(1.0); + index.put(SMALL_CIRCLE); + index.put(BIG_CIRCLE); + + assertThat(index.query(new Point(0.5, 0.5))).containsExactly(SMALL_CIRCLE, BIG_CIRCLE); + } + + @Test + void returnsEmptyWhenNoShapeNearby() { + ShapeIndex index = new ShapeIndex(); + index.put(SMALL_CIRCLE); + + assertThat(index.query(new Point(100, 100))).isEmpty(); + } + + @Test + void queryResultIsDeterministic() { + ShapeIndex index = new ShapeIndex(1.0); + index.put(SMALL_CIRCLE); + index.put(new StoredShape(4L, new Donut(1, 2, new Point(0, 0)))); + index.put(new StoredShape(5L, new Triangle(new Point(-1, -1), new Point(1, -1), + new Point(-1, 1)))); + + List first = List.copyOf(index.query(new Point(0, 0))); + List second = List.copyOf(index.query(new Point(0, 0))); + + assertThat(first).containsExactlyElementsOf(second); + } + + @Test + void clearRemovesAllShapes() { + ShapeIndex index = new ShapeIndex(1.0); + index.put(SMALL_CIRCLE); + index.put(BIG_CIRCLE); + + index.clear(); + + assertThat(index.query(new Point(0.5, 0.5))).isEmpty(); + assertThat(index.query(new Point(1000, 1000))).isEmpty(); + } +}