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
28 changes: 27 additions & 1 deletion core/src/main/scala/me/jeffmay/neo4j/client/cypher/Cypher.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,30 @@ object Cypher {
def toProps[T](obj: T)(implicit writer: CypherWrites.AsProps[T]): CypherProps = writer writes obj

/**
* Creates a [[CypherLabel]] to insert into the cypher query string.
* Creates a [[CypherStatementFragment]] to insert into a cypher query string for matching on the properties
* of a node.
*
* @param param the param object to expand into properties
* @param chopAndIndent whether to chop down and indent the properties with the given number of spaces
*
* @return a fragment of a [[CypherStatement]] that can be embedded into the properties selector of a node
*/
def expand(param: ImmutableParam, chopAndIndent: Option[Int] = None): CypherStatementFragment = {
val propTemplates = param.props.map {
case (k, v) => s"$k: {${param.namespace}}.$k"
}
val template = chopAndIndent match {
case Some(indentWidth) =>
val shim = new String(Array.fill(indentWidth)(' '))
propTemplates.mkString(s",\n$shim")
case None =>
propTemplates.mkString(", ")
}
CypherStatementFragment(CypherStatement(template, Map(param.namespace -> param.props)))
}

/**
* Creates a [[CypherLabel]] to insert into a cypher query string.
*
* @return A [[CypherResult]] which can either contain the label or an error.
*/
Expand All @@ -30,6 +53,7 @@ object Cypher {
*
* @return always successfully returns a [[CypherParamObject]]
*/
@deprecated("Use Cypher.obj(Cypher.params(namespace, props))", "0.8.0")
def obj(namespace: String, props: CypherProps): CypherParamObject = CypherParamObject(namespace, props)
def obj(params: ImmutableParam): CypherParamObject = CypherParamObject(params.namespace, params.props)

Expand All @@ -49,6 +73,7 @@ object Cypher {
* @param namespace the namespace to which all parameters inserted by this class share
*/
sealed abstract class Param(val namespace: String) {
require(!namespace.isEmpty, "Cypher.param() namespace cannot be empty string")
def isMutable: Boolean
protected def __clsName: String
override def toString: String = s"${__clsName}(namespace = $namespace)"
Expand Down Expand Up @@ -107,6 +132,7 @@ object Cypher {
*/
sealed abstract class ImmutableParam(namespace: String, val props: CypherProps)(implicit showProps: Show[CypherProps])
extends Param(namespace) with Proxy {
def toParams: CypherParams = Map(namespace -> props)
final override def isMutable: Boolean = false
override def self: Any = (namespace, props)
override def toString: String = s"${__clsName}(namespace = $namespace, props = ${showProps show props})"
Expand Down
41 changes: 37 additions & 4 deletions core/src/main/scala/me/jeffmay/neo4j/client/cypher/CypherArg.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package me.jeffmay.neo4j.client.cypher

import scala.util.matching.Regex

/**
* A valid argument to pass into the [[CypherStringContext]] used to insert some literal string or parameter(s)
* into a Cypher [[CypherStatement]].
Expand Down Expand Up @@ -47,7 +49,8 @@ sealed abstract class CypherTemplatePart(override val template: String) extends
final class CypherIdentifier private (name: String) extends CypherTemplatePart(name)
object CypherIdentifier {

val Valid = "^[a-zA-Z][a-zA-Z0-9_]*$".r
private[cypher] val ValidChars: String = "[a-zA-Z][a-zA-Z0-9_]*"
private[cypher] val Valid: Regex = s"^$ValidChars$$".r

def isValid(literal: String): Boolean = {
Valid.findFirstMatchIn(literal).isDefined
Expand All @@ -71,7 +74,8 @@ object CypherIdentifier {
final class CypherLabel private (name: String) extends CypherTemplatePart(s":$name")
object CypherLabel {

val Valid = "^[a-zA-Z0-9_]+$".r
private[cypher] val ValidChars: String = "[a-zA-Z0-9_]+"
private[cypher] val Valid: Regex = s"^$ValidChars$$".r

private[this] var validated: Map[String, CypherLabel] = Map.empty

Expand All @@ -92,6 +96,22 @@ object CypherLabel {
}
}

/**
* Marker trait for all [[CypherArg]]s that add [[CypherProps]] to a namespace.
*/
sealed trait CypherParamArg extends CypherArg {

/**
* The namespace in which the [[CypherProps]] live.
*/
def namespace: String

/**
* Extract the properties provided by this parameter.
*/
def toProps: CypherProps
}

/**
* Holds a single parameter field within one of the [[CypherStatement.parameters]] namespaces.
*
Expand All @@ -100,8 +120,11 @@ object CypherLabel {
* @param id the field name within the namespace
* @param value the value of the parameter object's field
*/
case class CypherParamField private[cypher] (namespace: String, id: String, value: CypherValue) extends CypherArg {
case class CypherParamField private[cypher] (namespace: String, id: String, value: CypherValue) extends CypherParamArg {

override val template: String = s"{$namespace}.$id"

override def toProps: CypherProps = Map(id -> value)
}

/**
Expand Down Expand Up @@ -141,6 +164,16 @@ case class CypherParamField private[cypher] (namespace: String, id: String, valu
* @param namespace the key of the [[CypherProps]] within which field names are unique
* @param props the map of fields to unfold as properties in place
*/
case class CypherParamObject private[cypher] (namespace: String, props: CypherProps) extends CypherArg {
case class CypherParamObject private[cypher] (namespace: String, props: CypherProps) extends CypherParamArg {
override val template: String = s"{ $namespace }"
override def toProps: CypherProps = props
}

/**
* Represents a fragment of cypher query to embed into another [[CypherStatement]].
*
* @param statement the fragment of template and any embedded [[CypherParams]].
*/
case class CypherStatementFragment private[cypher] (statement: CypherStatement) extends CypherArg {
override def template: String = statement.template
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ class CypherStringContext(val sc: StringContext) extends AnyVal {
*
* @return a [[CypherStatement]] with the concatenated template and the the template and parameters
* of the [[CypherStatement]].
* @throws InvalidCypherException if any of the args are [[CypherResultInvalid]]
* @throws CypherResultException if any of the args are [[CypherResultInvalid]]
* @throws ConflictingParameterFieldsException if two [[CypherParamField]]s share the same namespace and property name
* and the given values are different.
*/
def cypher(args: CypherResult[CypherArg]*): CypherStatement = {

// Build the literal query string
var count: Int = 0
val tmplParts: Seq[String] = args.map {
Expand All @@ -35,66 +36,56 @@ class CypherStringContext(val sc: StringContext) extends AnyVal {
case invalid: CypherResultInvalid => invalid
}
if (invalidArgs.nonEmpty) {
throw new InvalidCypherException(
throw new CypherResultException(
"Encountered errors at the |INVALID[#]| location markers in query.",
Some(template),
invalidArgs
)
}

// Separate the dynamic props from the static props
var dynamicFields = Seq.empty[CypherParamField]
var staticObjects = Seq.empty[CypherParamObject]
args foreach {
case CypherResultValid(p: CypherParamField) =>
dynamicFields :+= p
case CypherResultValid(p: CypherParamObject) =>
staticObjects :+= p
case _ =>
}
val objectsByNamespace = staticObjects.groupBy(_.namespace)
val fieldsByNamespace = dynamicFields.groupBy(_.namespace)

// Collect all the static parameter objects or throw an exception
// if any dynamic properties share the same namespace
val immutableParamObjects = objectsByNamespace
.map { case (namespace, objects) =>
if (fieldsByNamespace contains namespace) {
val conflictingParams = args.collect {
case CypherResultValid(p: CypherParamField) if p.namespace == namespace => Cypher.props(p.id -> p.value)
case CypherResultValid(p: CypherParamObject) if p.namespace == namespace => p.props
}
throw new MutatedParameterObjectException(namespace, conflictingParams, template)
}
else if (objects.toSet.size > 1) {
throw new ConflictingParameterObjectsException(namespace, objects.map(_.props), template)
// Collect all the field references
var foundParamFields = Map.empty[String, Set[String]]
var foundParamObjs = Map.empty[String, CypherProps]
val params = args.foldLeft(Map.empty.withDefaultValue(Map.empty: CypherProps): CypherParams) {
case (accParams, CypherResultValid(param: CypherParamArg)) =>
val ns = param.namespace
param match {
case obj: CypherParamObject =>
foundParamObjs.get(obj.namespace) match {
case Some(conflictingProps) if conflictingProps != obj.props =>
throw new ConflictingParameterObjectsException(ns, Seq(foundParamObjs(obj.namespace), obj.props), template)
case Some(duplicates) => // nothing to do, duplicate props already found
case _ =>
foundParamObjs += obj.namespace -> obj.props
}
case field: CypherParamField =>
foundParamFields.get(field.namespace) match {
case Some(props) =>
foundParamFields += field.namespace -> (props + field.id)
case None =>
foundParamFields += field.namespace -> Set(field.id)
}
}
else {
namespace -> objects.head.props
val nextProps = param.toProps
accParams.get(ns) match {
case Some(prevProps) =>
// Merge non-conflicting properties / duplicates into same namespace
accParams + (ns -> CypherStatement.mergeNamespace(template, ns, prevProps, nextProps))
case None =>
// Add non-conflicting parameter namespace
accParams + (ns -> nextProps)
}
}
case (accParams, _) => accParams
}

// Collect the dynamic parameter fields into properties objects
val mutableParamObjects = fieldsByNamespace
.map { case (namespace, fields) =>
val props: CypherProps = fields.groupBy(_.id).map { case (name, values) =>
// Allow duplicate values if they are equal
val conflictingValues = values.map(_.value)
if (conflictingValues.toSet.size > 1) {
throw new ConflictingParameterFieldsException(namespace, name, conflictingValues, template)
}
name -> values.head.value
}
namespace -> props
}
// Throw the first exception of any object references that conflict with field name references in the same namespace
for (conflictingNs <- foundParamObjs.keySet intersect foundParamFields.keySet) {
throw new MixedParamReferenceException(conflictingNs, foundParamFields(conflictingNs), template)
}

// We should not have any conflicts of namespace between static and dynamic properties
val params = immutableParamObjects ++ mutableParamObjects
assert(
params.size == immutableParamObjects.size + mutableParamObjects.size,
"Mutable and immutable param objects should never merge as " +
"combining the two should always throw a MutatedParameterObjectException"
)
CypherStatement(template, params)
// Return a statement that has been validated for missing or conflicting parameter values
val stmt = CypherStatement(template, params)
stmt.validate()
stmt
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ object CypherResult {
implicit def paramIdentResult(arg: Param): CypherResult[CypherIdentifier] = {
CypherIdentifier(arg.namespace)
}

/**
* All [[CypherStatement]]s can be embedded into other statements as [[CypherStatementFragment]]s.
*/
implicit def embedCypherStatement(stmt: CypherStatement): CypherResultValid[CypherStatementFragment] = {
CypherResultValid(CypherStatementFragment(stmt))
}
}

/**
Expand All @@ -53,7 +60,7 @@ case class CypherResultValid[+T <: CypherArg](result: T) extends CypherResult[T]
*/
case class CypherResultInvalid(result: InvalidCypherArg) extends CypherResult[Nothing] {
// Construct the exception on instantiation to insure that the stack-trace captures where the failure occurs.
val exception: Throwable = new InvalidCypherException(result.message, result.template)
val exception: Throwable = new CypherResultException(result.message, result.template)
final override def isValid: Boolean = false
final override def getOrThrow: Nothing = throw exception
}
Loading