Skip to content

Commit

Permalink
Documentation
Browse files Browse the repository at this point in the history
- Tests for most use cases
- Javadoc
- Switched A and B to L and R to make things more SQL like
  • Loading branch information
Luke-Sikina committed Aug 2, 2022
1 parent 1f21d95 commit fe1dc2d
Show file tree
Hide file tree
Showing 6 changed files with 166 additions and 49 deletions.
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Record Joiner

Given two records, A and B that share a common key k, and a third record X which contains the field k,
as well as a subset of the fields in A and B, this library will create a record X for each permutation
of A and B that share a key.
Given two records, `A` and `B` that share a common key `k`, and a third record `X` which contains the field `k`,
as well as a subset of the fields in `A` and `B`, this library will create a record `X` for each permutation
of `A` and `B` that share a key.

See `src/test/RecordJoinerTest.java` for examples.
123 changes: 84 additions & 39 deletions src/main/java/RecordJoiner.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,28 +16,66 @@
* right.
*/
public class RecordJoiner {
public <A extends Record, B extends Record, X extends Record, V> List<X> join(
List<A> a, RecordFieldHandle<A, V> fieldA,
List<B> b, RecordFieldHandle<B, V> fieldB,
Class<X> joined

/**
* Given two records RA and RB, make a function that gets a value from one of them.
* Which one is up to the implementer.
* @param <RA> record A
* @param <RB> record B
*/
@FunctionalInterface
public interface DualRecordAccessor<RA extends Record, RB extends Record> {
Object get(Pair<RA,RB> pair);
}

/**
* A pair of things. For streams
*/
private record Pair<F, S>(F first, S second){}


/**
* Performs an inner join on the rows of L and R, resulting in rows of X, where X is
* a record that contains a key field that it shares with at least L, and a set of other fields.
* Other fields that share a name with a field in L will be populated with the corresponding L value.
* Other fields that share a name with a field in R will be populated with the corresponding R value.
* Other fields will be assigned their zero value (null, 0, 0f, etc.)
*
* @param left a list of rows to be joined
* @param leftKey the function to get a key value from a left row
* @param right a list of rows to be joined
* @param rightKey the function to get a key value from a right row
* @param joinedRecordType the class of the resulting records when joining left and right
* @return rows of type joinedRecordType
* @param <V> the type of the common key
*/
public <L extends Record, R extends Record, X extends Record, V> List<X> innerJoin(
List<L> left, RecordFieldHandle<L, V> leftKey,
List<R> right, RecordFieldHandle<R, V> rightKey,
Class<X> joinedRecordType
) {
if (a.isEmpty() || b.isEmpty()) {
if (left.isEmpty() || right.isEmpty()) {
return List.of();
}
// create method mappings
Map<String, Method> aAccessors = getAccessors(a.get(0).getClass());
Map<String, Method> bAccessors = getAccessors(b.get(0).getClass());
Map<String, DualRecordAccessor<A, B>> joinedAccessors = joinAccessors(joined, aAccessors, bAccessors);
Constructor<X> joiningConstructor = getCanonicalConstructor(joined);
List<DualRecordAccessor<A, B>> orderedAccessors = Arrays.stream(joiningConstructor.getParameters())
Map<String, Method> lAccessors = getAccessors(left.get(0).getClass());
Map<String, Method> rAccessors = getAccessors(right.get(0).getClass());

// using the two method mappings, for each field in X's constructor,
// pick a method from aAccessors or bAccessors, or use a stub
Map<String, DualRecordAccessor<L, R>> joinedAccessors = joinAccessors(joinedRecordType, lAccessors, rAccessors);
Constructor<X> joiningConstructor = getCanonicalConstructor(joinedRecordType);
List<DualRecordAccessor<L, R>> orderedAccessors = Arrays.stream(joiningConstructor.getParameters())
.map(param -> joinedAccessors.get(param.getName()))
.toList();

Map<V, List<A>> groupedA = a.stream().collect(Collectors.groupingBy(fieldA::apply));
Map<V, List<B>> groupedB = b.stream().collect(Collectors.groupingBy(fieldB::apply));
// group the rows of a and b by their keys
Map<V, List<L>> groupedL = left.stream().collect(Collectors.groupingBy(leftKey::apply));
Map<V, List<R>> groupedR = right.stream().collect(Collectors.groupingBy(rightKey::apply));

return groupedA.entrySet().stream()
.flatMap(e -> permute(e.getValue(), groupedB.getOrDefault(e.getKey(), List.of())))
// for each common key, make a new row X for each permutation of a and b that share that key
return groupedL.entrySet().stream()
.flatMap(e -> permute(e.getValue(), groupedR.getOrDefault(e.getKey(), List.of())))
.flatMap(pair -> {
Object[] constructorArgs = orderedAccessors.stream()
.map(method -> method.get(pair))
Expand All @@ -51,16 +89,9 @@ public <A extends Record, B extends Record, X extends Record, V> List<X> join(
.toList();
}

@FunctionalInterface
private interface DualRecordAccessor<RA extends Record, RB extends Record> {
Object get(Pair<RA,RB> pair);
}

private record Pair<F, S>(F first, S second){};

private <A extends Record, B extends Record> Stream<Pair<A, B>> permute(List<A> a, List<B> b) {
return a.stream()
.flatMap(aa -> b.stream().map(bb -> new Pair<>(aa, bb)));
private <L extends Record, R extends Record> Stream<Pair<L, R>> permute(List<L> l, List<R> r) {
return l.stream()
.flatMap(aa -> r.stream().map(bb -> new Pair<>(aa, bb)));
}

private <A extends Record, B extends Record, X extends Record> Map<String, DualRecordAccessor<A, B>> joinAccessors(
Expand All @@ -69,32 +100,46 @@ private <A extends Record, B extends Record, X extends Record> Map<String, DualR
return getAccessors(joined).entrySet().stream().collect(Collectors.toMap(
Map.Entry::getKey,
e -> {
final Method methodA = aAccessors.get(e.getKey());
final Method methodB = bAccessors.get(e.getKey());
if (methodA != null) {
return (pair) -> {
try {
return methodA.invoke(pair.first);
} catch (IllegalAccessException | InvocationTargetException ex) {
throw new RuntimeException(ex);
}
};
} else if (methodB != null) {
final boolean useFirst = aAccessors.containsKey(e.getKey());
final Method toExecute = useFirst ? aAccessors.get(e.getKey()) : bAccessors.get(e.getKey());
if (toExecute == null) {
return (pair) -> getPrimitiveSafeValue(e.getValue().getReturnType());
} else {
return (pair) -> {
try {
return methodB.invoke(pair.second);
return toExecute.invoke(useFirst ? pair.first : pair.second);
} catch (IllegalAccessException | InvocationTargetException ex) {
throw new RuntimeException(ex);
}
};
} else {
// TODO: this will explode for primitives
return (pair) -> null;
}
}
));
}

private Object getPrimitiveSafeValue(Class<?> returnType) {
// ensures that unboxing primitive doesn't cause a NPE
if (!returnType.isPrimitive()) {
return null;
} else if (returnType.equals(int.class)) {
return 0;
} else if (returnType.equals(short.class)){
return (short) 0;
} else if (returnType.equals(byte.class)) {
return (byte) 0;
} else if (returnType.equals(long.class)) {
return 0L;
} else if (returnType.equals(float.class)) {
return 0.0f;
} else if (returnType.equals(double.class)) {
return 0.0d;
} else if (returnType.equals(char.class)) {
return (char) 0;
} else {
return false;
}
}

private <R extends Record> Map<String, Method> getAccessors(Class<R> recordClass) {
return Arrays.stream(recordClass.getRecordComponents())
.collect(Collectors.toMap(
Expand Down
2 changes: 0 additions & 2 deletions src/test/java/RecordA.java

This file was deleted.

2 changes: 0 additions & 2 deletions src/test/java/RecordAB.java

This file was deleted.

2 changes: 0 additions & 2 deletions src/test/java/RecordB.java

This file was deleted.

78 changes: 77 additions & 1 deletion src/test/java/RecordJoinerTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
import java.util.List;

class RecordJoinerTest {
public record RecordA(int key, String valA) {}

public record RecordAWithExtraField(int key, String valA, long notInAB) {}
public record RecordB(int key, String valB) {}
public record RecordAB(int key, String valA, String valB) {}

public record RecordABAndNullField(int key, String valA, String valB, float notInAOrB) {}

RecordJoiner subject = new RecordJoiner();

Expand All @@ -12,9 +19,78 @@ void shouldJoin() {
List<RecordA> as = List.of(new RecordA(1, "foo"));
List<RecordB> bs = List.of(new RecordB(1, "bar"));

List<RecordAB> actual = subject.join(as, RecordA::key, bs, RecordB::key, RecordAB.class);
List<RecordAB> actual = subject.innerJoin(as, RecordA::key, bs, RecordB::key, RecordAB.class);
List<RecordAB> expected = List.of(new RecordAB(1, "foo", "bar"));

Assertions.assertEquals(expected, actual);
}

@Test
void shouldJoinAndIgnoreExtraFieldInResult() {
List<RecordA> as = List.of(new RecordA(1, "foo"));
List<RecordB> bs = List.of(new RecordB(1, "bar"));

List<RecordABAndNullField> actual = subject.innerJoin(as, RecordA::key, bs, RecordB::key, RecordABAndNullField.class);
List<RecordABAndNullField> expected = List.of(new RecordABAndNullField(1, "foo", "bar", 0f));

Assertions.assertEquals(expected, actual);
}

@Test
void shouldJoinAndIgnoreExtraFieldInA() {
List<RecordAWithExtraField> as = List.of(new RecordAWithExtraField(1, "foo", 0L));
List<RecordB> bs = List.of(new RecordB(1, "bar"));

List<RecordAB> actual = subject.innerJoin(as, RecordAWithExtraField::key, bs, RecordB::key, RecordAB.class);
List<RecordAB> expected = List.of(new RecordAB(1, "foo", "bar"));

Assertions.assertEquals(expected, actual);
}

@Test
void shouldJoinOneToMany() {
List<RecordA> as = List.of(new RecordA(1, "foo"));
List<RecordB> bs = List.of(new RecordB(1, "bar"), new RecordB(1, "baz"));

List<RecordAB> actual = subject.innerJoin(as, RecordA::key, bs, RecordB::key, RecordAB.class);
List<RecordAB> expected = List.of(new RecordAB(1, "foo", "bar"), new RecordAB(1, "foo", "baz"));

Assertions.assertEquals(expected, actual);
}

@Test
void shouldJoinManyToMany() {
List<RecordA> as = List.of(new RecordA(1, "foo"), new RecordA(1, "fah"));
List<RecordB> bs = List.of(new RecordB(1, "bar"), new RecordB(1, "baz"));

List<RecordAB> actual = subject.innerJoin(as, RecordA::key, bs, RecordB::key, RecordAB.class);
List<RecordAB> expected = List.of(
new RecordAB(1, "foo", "bar"), new RecordAB(1, "foo", "baz"),
new RecordAB(1, "fah", "bar"), new RecordAB(1, "fah", "baz")
);

Assertions.assertEquals(expected, actual);
}

@Test
void shouldJoinManyToOne() {
List<RecordA> as = List.of(new RecordA(1, "foo"), new RecordA(1, "fah"));
List<RecordB> bs = List.of(new RecordB(1, "bar"));

List<RecordAB> actual = subject.innerJoin(as, RecordA::key, bs, RecordB::key, RecordAB.class);
List<RecordAB> expected = List.of(new RecordAB(1, "foo", "bar"), new RecordAB(1, "fah", "bar"));

Assertions.assertEquals(expected, actual);
}

@Test
void shouldNotJoinIfEmpty() {
List<RecordA> as = List.of();
List<RecordB> bs = List.of(new RecordB(1, "bar"));

List<RecordAB> actual = subject.innerJoin(as, RecordA::key, bs, RecordB::key, RecordAB.class);
List<RecordAB> expected = List.of();

Assertions.assertEquals(expected, actual);
}
}

0 comments on commit fe1dc2d

Please sign in to comment.