Skip to content

Commit

Permalink
Allow a record to have fields of types defined in separate .avsc files
Browse files Browse the repository at this point in the history
Alt solution to julianpeeters#31
  • Loading branch information
rjharmon committed Dec 10, 2017
1 parent 0cc0b4c commit 5744ec8
Show file tree
Hide file tree
Showing 8 changed files with 86 additions and 19 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## Herein lie assorted macro annotations for working with Avro in Scala:

1) `@AvroTypeProvider("path/to/schema")` - Convert Avro Schemas to Scala case class definitions for use in your favorite Scala Avro runtime.
1) `@AvroTypeProvider("path/to/schema" [, ...])` - Convert Avro Schemas to Scala case class definitions for use in your favorite Scala Avro runtime.


2) `@AvroRecord` - Use Scala case classes to represent your Avro SpecificRecords, serializable by the Apache Avro runtime (a port of [Avro-Scala-Compiler-Plugin](https://code.google.com/p/avro-scala-compiler-plugin/)).
Expand Down Expand Up @@ -106,7 +106,7 @@ Annotate an "empty" case class, and its members will be generated automatically
```

#### Please note:
1) The datafile must be available at compile time.
1) The datafile(s) must be available at compile time.

2) The filepath must be a String literal.

Expand All @@ -116,6 +116,7 @@ Annotate an "empty" case class, and its members will be generated automatically

5) A class that is doubly annotated with `@AvroTypeProvider` and `@AvroRecord` will be updated with vars instead of vals.

6) If one of your classes has a member defined in a separate schema file, a) see #4 above, and b) give both schemas (with dependencies listed first) to @AvroTypeProvider(). This is for the Avro parser.


## 2) Avro-Record:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import collection.JavaConversions._
import java.io.File

import com.typesafe.scalalogging._
import org.apache.avro.Schema

object AvroTypeProviderMacro extends LazyLogging {

Expand Down Expand Up @@ -39,9 +40,17 @@ object AvroTypeProviderMacro extends LazyLogging {
logger.info(s"Current path: ${new File(".").getAbsolutePath}")

// get the schema for the record that this class represents
val avroFilePath = FilePathProbe.getPath(c)
val infile = new File(avroFilePath)
val fileSchemas = FileParser.getSchemas(infile)
// along with any nested schemas needed to support it, whose definitions may come from other .avsc files.
// these dependendencies must be resolved in order - specify deps first, dependent classes second.
val avroFiles = FilePathProbe.getPath(c)

val myParser = new Schema.Parser()
val fileSchemas : List[Schema] = avroFiles.map { fileName =>
val inFile = new File(fileName)
FileParser.getSchemas(inFile, myParser)
}.flatten


val nestedSchemas = fileSchemas.flatMap(NestedSchemaExtractor.getNestedSchemas)
// first try matching schema record full name to class full name, then by the
// regular name in case we're trying to read from a non-namespaced schema
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ import scala.collection.JavaConverters._

object FileParser {

def getSchemas(infile: java.io.File): List[Schema] = {
def getSchemas(infile: java.io.File, parser : Parser): List[Schema] = {
val schema = infile.getName.split("\\.").last match {
case "avro" =>
val gdr = new GenericDatumReader[GenericRecord]
val dfr = new DataFileReader(infile, gdr)
dfr.getSchema
case "avsc" =>
new Parser().parse(infile)
parser.parse(infile)
case _ => throw new Exception("Invalid file ending. Must be .avsc for plain text json files and .avro for binary files.")
}
schema.getType match {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,31 @@ import java.io.File
object FilePathProbe {

//here's how we get the value of the filepath, it's the arg to the annotation
def getPath(c: Context) = {
def getPath(c: Context) : List[String] = {
import c.universe._
import Flag._

val path = c.prefix.tree match {
case Apply(_, List(Literal(Constant(x)))) => x.toString
val paths: List[String] = c.prefix.tree match {
case q"new AvroTypeProvider(..$args)" => {
args.map(arg => arg match {
case Literal(Constant(fp: String)) => fp.toString()
})
}
case _ => c.abort(c.enclosingPosition, "file path not found, annotation argument must be a constant")
}

if (new File(path).exists) path else {
// Allow paths relative to where the enclosing class is. This allows the working
// directory to be different from the project base directory, which is useful
// for example with sbt `ProjectRef` referencing a github URL and compiling the
// project behind the scenes.
val callSite = new File(c.macroApplication.pos.source.file.path).getParent
val relative = new File(callSite, path)
if (relative.exists) relative.getAbsolutePath else path
}
paths.map(path => {
val fp = new File(path)
if (fp.exists) path else {
// Allow paths relative to where the enclosing class is. This allows the working
// directory to be different from the project base directory, which is useful
// for example with sbt `ProjectRef` referencing a github URL and compiling the
// project behind the scenes.
val callSite = new File(c.macroApplication.pos.source.file.path).getParent
val relative = new File(callSite, path)
if (relative.exists) relative.getAbsolutePath else path
}
})
}

}
9 changes: 9 additions & 0 deletions tests/src/test/resources/separate/SeparateMetaData.avsc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"type": "record",
"name": "SeparateMetaData",
"namespace": "test",
"fields": [
{"name": "source", "type": "string"},
{"name": "timestamp", "type": "string"}
]
}
12 changes: 12 additions & 0 deletions tests/src/test/resources/separate/SeparateTestMessage.avsc
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"type": "record",
"name": "SeparateTestMessage",
"namespace": "test",
"fields": [
{"name": "message", "type": "string"},
{
"name": "metaData",
"type": "SeparateMetaData"
}
]
}
11 changes: 11 additions & 0 deletions tests/src/test/scala/AvroTypeProviderTestClasses.scala
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,14 @@ case class TestMessage()
@AvroTypeProvider("tests/src/test/resources/AvroTypeProviderTestNestedSchemaFile.avsc")
@AvroRecord
case class MetaData()

@AvroTypeProvider("tests/src/test/resources/separate/SeparateMetaData.avsc")
@AvroRecord
case class SeparateMetaData()

// nested record from separate schema files instead of the same schema file
@AvroTypeProvider( "tests/src/test/resources/separate/SeparateMetaData.avsc",
"tests/src/test/resources/separate/SeparateTestMessage.avsc")
@AvroRecord
case class SeparateTestMessage()

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@

package test
// Specs2
import org.specs2.mutable.Specification

import java.io.File

import com.julianpeeters.avro.annotations._

class AvroTypeProviderNestedSeparateSchemaFilesTest extends Specification {

"A case class with types provided from two separate .avsc files" should {
"serialize and deserialize correctly" in {
val record = SeparateTestMessage("Achilles", SeparateMetaData("ow", "12345"))
TestUtil.verifyWriteAndRead(List(record))
}
}
}

0 comments on commit 5744ec8

Please sign in to comment.