Skip to content

Commit

Permalink
JsonConfig initial working
Browse files Browse the repository at this point in the history
  • Loading branch information
Greg Zoller committed Nov 28, 2023
1 parent b13979c commit bb41444
Show file tree
Hide file tree
Showing 22 changed files with 1,386 additions and 505 deletions.
Binary file removed .DS_Store
Binary file not shown.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.DS_Store
out/
.vscode/
.metals/
Expand Down
66 changes: 4 additions & 62 deletions TODO.txt
Original file line number Diff line number Diff line change
@@ -1,66 +1,8 @@
Path to Performance
-------------------

[ ] - Fix string value in JsonOutput to handle escaping " and anything else--maybe use apache library


[ ] - Create def() for each class (and maybe each collection?) found, to output json as seamlessly as possible

-- Possibly a multi-pass generation? Once to identify all the classes and generate def fn names, and another
to actually generate the code in dependency order

[*] - Create JsonOutput (like Jsoniter's JsonWriter, but the name conflicts w/mine). Initially continue to use
StringBuilder, but use some of Jsoniter's clever ',' handling

[ ] - Explore dark magic Jsoniter mechanisms for all that bitwise/byte stuff to drive extreme efficiency

[*] - In scala-reflection, explode PrimitiveRTypeRef into separate Refs, like they have for primitive RTypes


LEARNINGS: Jsoniter macros to gen an list:

... else if (tpe <:< TypeRepr.of[List[_]]) withEncoderFor(methodKey, m, out) { (out, x) =>
val tpe1 = typeArg1(tpe)
tpe1.asType match
case '[t1] =>
val tx = x.asExprOf[List[t1]]
'{
$out.writeArrayStart()
var l = $tx
while (l ne Nil) {
${genWriteVal('{ l.head }, tpe1 :: types, isStringified, None, out)}
l = l.tail
}
$out.writeArrayEnd()
}
}

Need to learn what "withEnocderFor()" does--specifically how $out gets passed in.


This is the dark magic.... It creates a Symbol and a method (DefDef) for the given function f.
Not sure how to connect/call these together yet, but in isolation, this should allow us to
generate discrete functions that output a "thing".

def withEncoderFor[T: Type](methodKey: EncoderMethodKey, arg: Expr[T], out: Expr[JsonWriter])
(f: (Expr[JsonWriter], Expr[T])=> Expr[Unit]): Expr[Unit] =
Apply(Ref(encodeMethodSyms.getOrElse(methodKey, {
val sym = Symbol.newMethod(Symbol.spliceOwner, "e" + encodeMethodSyms.size,
MethodType(List("x", "out"))(_ => List(TypeRepr.of[T], TypeRepr.of[JsonWriter]), _ => TypeRepr.of[Unit]))
encodeMethodSyms.update(methodKey, sym)
encodeMethodDefs += DefDef(sym, params => {
val List(List(x, out)) = params
Some(f(out.asExprOf[JsonWriter], x.asExprOf[T]).asTerm.changeOwner(sym))
})
sym
})), List(arg.asTerm, out.asTerm)).asExprOf[Unit]


Here's the EncoderMethodKey thingy:

case class EncoderMethodKey(tpe: TypeRepr, isStringified: Boolean, discriminatorKeyValue: Option[(String, String)])

val encodeMethodSyms = new mutable.HashMap[EncoderMethodKey, Symbol]
val encodeMethodDefs = new mutable.ArrayBuffer[DefDef]

Not sure if we need all this drama yet or not.... Perhaps we can use this as a simple wrapper around the TypedName. Then if
it needs to store more, the shell is already wired in.
[ ] - Study Jsoniter and how it propagates its write config between compile-time and run-time (usage too)
Ideally we'd love to be able to see cfg at compile-time and geneate accordingly.... Right now,
ScalaJack's config is purely runtime, which is less convenient for us...
4 changes: 2 additions & 2 deletions benchmark/src/main/scala/co.blocke/ScalaJack.scala
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@ object ScalaJackZ:

trait ScalaJackWritingBenchmark {
@Benchmark
def writeRecordScalaJack = sj[Record].toJson(record) // 677K score
// def writeRecordScalaJack = ScalaJack[Record].toJson(record) // 1.7M score <- faster
// def writeRecordScalaJack = sj[Record].toJson(record) // 677K score
def writeRecordScalaJack = ScalaJack[Record].toJson(record) // 1.7M score <- faster
}
22 changes: 16 additions & 6 deletions src/main/scala/co.blocke.scalajack/ScalaJack.scala
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import quoted.Quotes
import json.*

case class ScalaJack[T](jsonDecoder: reading.JsonDecoder[T], jsonEncoder: JsonCodec[T]): // extends JsonCodec[T] //with YamlCodec with MsgPackCodec
def fromJson(js: String)(using cfg: JsonConfig = JsonConfig()): Either[JsonParseError, T] =
def fromJson(js: String): Either[JsonParseError, T] =
jsonDecoder.decodeJson(js)

val out = writing.JsonOutput() // let's clear & re-use JsonOutput--avoid re-allocating all the internal buffer space
def toJson(a: T)(using cfg: JsonConfig = JsonConfig()): String =
def toJson(a: T): String =
jsonEncoder.encodeValue(a, out.clear())
out.result

Expand All @@ -22,14 +22,24 @@ object ScalaJack:

def apply[A](implicit a: ScalaJack[A]): ScalaJack[A] = a

// ----- Use default JsonConfig
inline def sj[T]: ScalaJack[T] = ${ sjImpl[T] }

def sjImpl[T](using q: Quotes, tt: Type[T]): Expr[ScalaJack[T]] =
import q.reflect.*
def sjImpl[T: Type](using Quotes): Expr[ScalaJack[T]] =
import quotes.reflect.*
val classRef = ReflectOnType[T](quotes)(TypeRepr.of[T], true)(using scala.collection.mutable.Map.empty[TypedName, Boolean])
val jsonDecoder = reading.JsonReader.refRead(classRef)
val jsonEncoder = writing.JsonCodecMaker.generateCodecFor(classRef)
val jsonEncoder = writing.JsonCodecMaker.generateCodecFor(classRef, JsonConfig)

'{ ScalaJack($jsonDecoder, $jsonEncoder) }

// ----- Use given JsonConfig
inline def sj[T](inline cfg: JsonConfig): ScalaJack[T] = ${ sjImplWithConfig[T]('cfg) }
def sjImplWithConfig[T: Type](cfgE: Expr[JsonConfig])(using Quotes): Expr[ScalaJack[T]] =
import quotes.reflect.*
val cfg = summon[FromExpr[JsonConfig]].unapply(cfgE)
val classRef = ReflectOnType[T](quotes)(TypeRepr.of[T], true)(using scala.collection.mutable.Map.empty[TypedName, Boolean])
val jsonDecoder = reading.JsonReader.refRead(classRef)
val jsonEncoder = writing.JsonCodecMaker.generateCodecFor(classRef, cfg.getOrElse(JsonConfig))
'{ ScalaJack($jsonDecoder, $jsonEncoder) }

// refRead[T](classRef)
Expand Down
16 changes: 0 additions & 16 deletions src/main/scala/co.blocke.scalajack/internal/TreeNode.scala

This file was deleted.

13 changes: 0 additions & 13 deletions src/main/scala/co.blocke.scalajack/json/JsonCodec.scalax

This file was deleted.

160 changes: 147 additions & 13 deletions src/main/scala/co.blocke.scalajack/json/JsonConfig.scala
Original file line number Diff line number Diff line change
@@ -1,24 +1,158 @@
package co.blocke.scalajack
package json

case class JsonConfig(
noneAsNull: Boolean = false,
forbidNullsInInput: Boolean = false,
tryFailureHandling: TryOption = TryOption.NO_WRITE,
undefinedFieldHandling: UndefinedValueOption = UndefinedValueOption.THROW_EXCEPTION,
permissivePrimitives: Boolean = false,
writeNonConstructorFields: Boolean = false,
import co.blocke.scala_reflection.TypedName
import co.blocke.scala_reflection.reflect.*
import co.blocke.scala_reflection.reflect.rtypeRefs.*
import scala.quoted.*

class JsonConfig private[scalajack] (
val noneAsNull: Boolean,
// forbidNullsInInput: Boolean = false,
// tryFailureHandling: TryOption = TryOption.NO_WRITE,
// undefinedFieldHandling: UndefinedValueOption = UndefinedValueOption.THROW_EXCEPTION,
// permissivePrimitives: Boolean = false,
// writeNonConstructorFields: Boolean = false,
// --------------------------
typeHintLabel: String = "_hint",
typeHintLabelByTrait: Map[String, String] = Map.empty[String, String], // Trait name -> type hint label
typeHintDefaultTransformer: String => String = (v: String) => v, // in case you want something different than class name (simple name re-mapping)
typeHintTransformer: Map[String, Any => String] = Map.empty[String, Any => String], // if you want class-specific control (instance value => String)
val typeHintLabel: String,
val typeHintPolicy: TypeHintPolicy = TypeHintPolicy.SIMPLE_CLASSNAME
// --------------------------
enumsAsIds: Char | List[String] = Nil // Char is '*' for all enums as ids, or a list of fully-qualified class names
)
// enumsAsIds: Option[List[String]] = None // None=no enums as ids, Some(Nil)=all enums as ids, Some(List(...))=specified classes enums as ids
):
def withNoneAsNull(nan: Boolean): JsonConfig = copy(noneAsNull = nan)
def withTypeHintLabel(label: String): JsonConfig = copy(typeHintLabel = label)
def withTypeHintPolicy(hintPolicy: TypeHintPolicy): JsonConfig = copy(typeHintPolicy = hintPolicy)

private[this] def copy(
noneAsNull: Boolean = noneAsNull,
typeHintLabel: String = typeHintLabel,
typeHintPolicy: TypeHintPolicy = typeHintPolicy
): JsonConfig = new JsonConfig(
noneAsNull,
typeHintLabel,
typeHintPolicy
)

enum TryOption:
case AS_NULL, NO_WRITE, ERR_MSG_STRING, THROW_EXCEPTION

enum UndefinedValueOption:
case AS_NULL, AS_SYMBOL, THROW_EXCEPTION

enum TypeHintPolicy:
case SIMPLE_CLASSNAME, SCRAMBLE_CLASSNAME, USE_ANNOTATION

object JsonConfig
extends JsonConfig(
noneAsNull = false,
typeHintLabel = "_hint",
typeHintPolicy = TypeHintPolicy.SIMPLE_CLASSNAME
):
import scala.quoted.FromExpr.*

private[scalajack] given FromExpr[JsonConfig] with {

def extract[X: FromExpr](name: String, x: Expr[X])(using Quotes): X =
import quotes.reflect.*
summon[FromExpr[X]].unapply(x).getOrElse(throw JsonConfigError(s"Can't parse $name: ${x.show}, tree: ${x.asTerm}"))

def unapply(x: Expr[JsonConfig])(using Quotes): Option[JsonConfig] =
import quotes.reflect.*

x match
case '{
JsonConfig(
$noneAsNullE,
// $forbitNullsInInputE,
// $tryFailureHandlerE,
// $undefinedFieldHandlingE,
// $permissivePrimitivesE,
// $writeNonConstructorFieldsE,
$typeHintLabelE,
$typeHintPolicyE
// $enumsAsIdsE
)
} =>
try
Some(
JsonConfig(
extract("noneAsNull", noneAsNullE),
// extract("forbitNullsInInput", forbitNullsInInputE),
// extract("tryFailureHandler", tryFailureHandlerE),
// extract("undefinedFieldHandling", undefinedFieldHandlingE),
// extract("permissivePrimitives", permissivePrimitivesE),
// extract("writeNonConstructorFields", writeNonConstructorFieldsE),
// extract2[String]("typeHintLabel", x)
extract("typeHintLabel", typeHintLabelE),
extract("typeHintPolicy", typeHintPolicyE)
// extract("enumsAsIds", enumsAsIdsE)
)
)
catch {
case x =>
println("ERROR: " + x.getMessage)
None
}
case '{ JsonConfig } => Some(JsonConfig)
case '{ ($x: JsonConfig).withNoneAsNull($v) } => Some(x.valueOrAbort.withNoneAsNull(v.valueOrAbort))
case '{ ($x: JsonConfig).withTypeHintLabel($v) } => Some(x.valueOrAbort.withTypeHintLabel(v.valueOrAbort))
case '{ ($x: JsonConfig).withTypeHintPolicy($v) } => Some(x.valueOrAbort.withTypeHintPolicy(v.valueOrAbort))
case z =>
println("Z: " + z.show)
None
}

private[scalajack] given FromExpr[TryOption] with {
def unapply(x: Expr[TryOption])(using Quotes): Option[TryOption] =
import quotes.reflect.*
x match
case '{ TryOption.AS_NULL } => Some(TryOption.AS_NULL)
case '{ TryOption.NO_WRITE } => Some(TryOption.NO_WRITE)
case '{ TryOption.ERR_MSG_STRING } => Some(TryOption.ERR_MSG_STRING)
case '{ TryOption.THROW_EXCEPTION } => Some(TryOption.THROW_EXCEPTION)
}

private[scalajack] given FromExpr[UndefinedValueOption] with {
def unapply(x: Expr[UndefinedValueOption])(using Quotes): Option[UndefinedValueOption] =
import quotes.reflect.*
x match
case '{ UndefinedValueOption.AS_NULL } => Some(UndefinedValueOption.AS_NULL)
case '{ UndefinedValueOption.AS_SYMBOL } => Some(UndefinedValueOption.AS_SYMBOL)
case '{ UndefinedValueOption.THROW_EXCEPTION } => Some(UndefinedValueOption.THROW_EXCEPTION)
}

private[scalajack] given FromExpr[TypeHintPolicy] with {
def unapply(x: Expr[TypeHintPolicy])(using Quotes): Option[TypeHintPolicy] =
import quotes.reflect.*
x match
case '{ TypeHintPolicy.SIMPLE_CLASSNAME } => Some(TypeHintPolicy.SIMPLE_CLASSNAME)
case '{ TypeHintPolicy.SCRAMBLE_CLASSNAME } => Some(TypeHintPolicy.SCRAMBLE_CLASSNAME)
case '{ TypeHintPolicy.USE_ANNOTATION } => Some(TypeHintPolicy.USE_ANNOTATION)
}

/*
Here's how we use Quotes to get default values from a class...def
// Constructor argument list, preloaded with optional 'None' values and any default values specified
val preloaded = Expr
.ofList(r.fields.map { f =>
val scalaF = f.asInstanceOf[ScalaFieldInfoRef]
if scalaF.defaultValueAccessorName.isDefined then
r.refType match
case '[t] =>
val tpe = TypeRepr.of[t].widen
val sym = tpe.typeSymbol
val companionBody = sym.companionClass.tree.asInstanceOf[ClassDef].body
val companion = Ref(sym.companionModule)
companionBody
.collect {
case defaultMethod @ DefDef(name, _, _, _) if name.startsWith("$lessinit$greater$default$" + (f.index + 1)) =>
companion.select(defaultMethod.symbol).appliedToTypes(tpe.typeArgs).asExpr
}
.headOption
.getOrElse(Expr(null.asInstanceOf[Boolean]))
else if scalaF.fieldRef.isInstanceOf[OptionRef[_]] then Expr(None)
else Expr(null.asInstanceOf[Int])
})
*/
5 changes: 4 additions & 1 deletion src/main/scala/co.blocke.scalajack/json/JsonError.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package co.blocke.scalajack
package json

class JsonError(msg: String) extends Throwable
class JsonIllegalKeyType(msg: String) extends Throwable(msg)
class JsonNullKeyValue(msg: String) extends Throwable(msg)
class JsonUnsupportedType(msg: String) extends Throwable(msg)
class JsonConfigError(msg: String) extends Throwable(msg)

class ParseError(val msg: String) extends Throwable(msg):
val show: String = ""
Expand Down
14 changes: 14 additions & 0 deletions src/main/scala/co.blocke.scalajack/json/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,17 @@ val BUFFER_EXCEEDED: Char = 7 // Old "BELL" ASCII value, used as a marker when w
val END_OF_STRING: Char = 3

inline def lastPart(n: String) = n.split('.').last.stripSuffix("$")

val random = new scala.util.Random()
def scramble(hash: Int): String =
val last5 = f"$hash%05d".takeRight(5)
val digits = (1 to 5).map(_ => random.nextInt(10))
if digits(0) % 2 == 0 then s"${last5(0)}${digits(0)}${last5(1)}${digits(1)}${last5(2)}-${digits(2)}${last5(3)}${digits(3)}-${last5(4)}${digits(4)}A"
else s"${digits(0)}${last5(0)}${digits(1)}${last5(1)}${digits(2)}-${last5(2)}${digits(3)}${last5(3)}-${digits(4)}${last5(4)}B"

def descrambleTest(in: String, hash: Int): Boolean =
val last5 = f"$hash%05d".takeRight(5)
in.last match
case 'A' if in.length == 13 => "" + in(0) + in(2) + in(4) + in(7) + in(10) == last5
case 'B' if in.length == 13 => "" + in(1) + in(3) + in(6) + in(8) + in(11) == last5
case _ => false
11 changes: 11 additions & 0 deletions src/main/scala/co.blocke.scalajack/json/reading/JsonReader.scala
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,21 @@ import scala.collection.Factory
*/
object JsonReader:

// Temporary no-op reader...
def refRead[T](
ref: RTypeRef[T]
)(using q: Quotes, tt: Type[T]): Expr[JsonDecoder[T]] =
import quotes.reflect.*
'{
new JsonDecoder[T] {
def unsafeDecode(in: JsonSource): T = null.asInstanceOf[T]
}
}

def refRead2[T](
ref: RTypeRef[T]
)(using q: Quotes, tt: Type[T]): Expr[JsonDecoder[T]] =
import quotes.reflect.*

ref match
case r: PrimitiveRef =>
Expand Down
Loading

0 comments on commit bb41444

Please sign in to comment.