Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -134,7 +134,7 @@ Shapes can also be loaded from a file with `-f <filename>` (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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public class PointCommand extends AbstractCommand {

@Override
public void process() {
List<StoredShape> containing = persistence.getAll().parallelStream()
List<StoredShape> containing = persistence.queryContaining(point).parallelStream()
.filter(stored -> stored.shape().inShape(point))
.toList();
printShapes(containing);
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/com/progys/interview/quiz/model/BoundingBox.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
6 changes: 6 additions & 0 deletions src/main/java/com/progys/interview/quiz/model/Circle.java
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/com/progys/interview/quiz/model/Donut.java
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/progys/interview/quiz/model/Shape.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ public sealed interface Shape permits Circle, Triangle, Donut {
double getArea();

boolean inShape(Point point);

BoundingBox getBounds();
}
9 changes: 9 additions & 0 deletions src/main/java/com/progys/interview/quiz/model/Triangle.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -29,12 +30,15 @@ public class ObjectStore implements Store {
private final EntityManagerFactory entityManagerFactory;
private final EntityManager manager;
private final List<StoredShape> 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
Expand All @@ -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()) {
Expand All @@ -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();
Expand All @@ -80,6 +86,11 @@ public Collection<StoredShape> getAll() {
return Collections.unmodifiableList(shapes);
}

@Override
public Collection<StoredShape> queryContaining(Point point) {
return index.query(point);
}

private List<StoredShape> loadShapesFromDatabase() {
TypedQuery<ShapeEntity> query = manager.createQuery(
"SELECT e FROM " + ShapeEntity.class.getName() + " e", ShapeEntity.class);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Cell, List<StoredShape>> cells = new HashMap<>();
private final List<StoredShape> 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<StoredShape> query(Point point) {
List<StoredShape> candidates =
cells.getOrDefault(new Cell(cell(point.x), cell(point.y)), List.of());
if (largeShapes.isEmpty()) {
return candidates;
}
List<StoredShape> 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) {
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,5 +15,7 @@ public interface Store {

Collection<StoredShape> getAll();

Collection<StoredShape> queryContaining(Point point);

void close();
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 =
Expand All @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<StoredShape> 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)));
Expand Down
Original file line number Diff line number Diff line change
@@ -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<StoredShape> first = List.copyOf(index.query(new Point(0, 0)));
List<StoredShape> 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();
}
}
Loading