diff --git a/core/src/main/scala/lightdb/Field.scala b/core/src/main/scala/lightdb/Field.scala index b6a05916..6c94dc16 100644 --- a/core/src/main/scala/lightdb/Field.scala +++ b/core/src/main/scala/lightdb/Field.scala @@ -28,6 +28,8 @@ sealed class Field[Doc <: Document[Doc], V](val name: String, override def !==(value: V): Filter[Doc] = Filter.NotEquals(name, value) + override def regex(expression: String): Filter[Doc] = Filter.Regex(name, expression) + override protected def rangeLong(from: Option[Long], to: Option[Long]): Filter[Doc] = Filter.RangeLong(name, from, to) diff --git a/core/src/main/scala/lightdb/SortDirection.scala b/core/src/main/scala/lightdb/SortDirection.scala index ff823bdf..a5ad9165 100644 --- a/core/src/main/scala/lightdb/SortDirection.scala +++ b/core/src/main/scala/lightdb/SortDirection.scala @@ -1,9 +1,12 @@ package lightdb +import fabric.rw.RW + sealed trait SortDirection object SortDirection { - case object Ascending extends SortDirection + implicit val rw: RW[SortDirection] = RW.enumeration(List(Ascending, Descending)) + case object Ascending extends SortDirection case object Descending extends SortDirection } \ No newline at end of file diff --git a/core/src/main/scala/lightdb/aggregate/AggregateFilter.scala b/core/src/main/scala/lightdb/aggregate/AggregateFilter.scala index cf5fafca..6302b602 100644 --- a/core/src/main/scala/lightdb/aggregate/AggregateFilter.scala +++ b/core/src/main/scala/lightdb/aggregate/AggregateFilter.scala @@ -1,6 +1,6 @@ package lightdb.aggregate -import fabric.Json +import fabric.{Json, Str} import lightdb.Field import lightdb.doc.Document import lightdb.spatial.GeoPoint @@ -26,6 +26,10 @@ object AggregateFilter { def getJson: Json = field.rw.read(value) } + case class Regex[Doc <: Document[Doc], F](name: String, field: Field[Doc, F], expression: String) extends AggregateFilter[Doc] { + def getJson: Json = Str(expression) + } + case class In[Doc <: Document[Doc], F](name: String, field: Field[Doc, F], values: Seq[F]) extends AggregateFilter[Doc] { def getJson: List[Json] = values.toList.map(field.rw.read) } diff --git a/core/src/main/scala/lightdb/aggregate/AggregateFunction.scala b/core/src/main/scala/lightdb/aggregate/AggregateFunction.scala index 52dfc4d0..1d857524 100644 --- a/core/src/main/scala/lightdb/aggregate/AggregateFunction.scala +++ b/core/src/main/scala/lightdb/aggregate/AggregateFunction.scala @@ -18,6 +18,8 @@ case class AggregateFunction[T, V, Doc <: Document[Doc]](name: String, field: Fi override def !==(value: V): AggregateFilter[Doc] = AggregateFilter.NotEquals(name, field, value) + override def regex(expression: String): AggregateFilter[Doc] = AggregateFilter.Regex(name, field, expression) + override protected def rangeLong(from: Option[Long], to: Option[Long]): AggregateFilter[Doc] = AggregateFilter.RangeLong(name, field.asInstanceOf[Field[Doc, Long]], from, to) diff --git a/core/src/main/scala/lightdb/filter/Condition.scala b/core/src/main/scala/lightdb/filter/Condition.scala index b7cfe445..1bb04a7a 100644 --- a/core/src/main/scala/lightdb/filter/Condition.scala +++ b/core/src/main/scala/lightdb/filter/Condition.scala @@ -1,8 +1,12 @@ package lightdb.filter +import fabric.rw.RW + trait Condition object Condition { + implicit val rw: RW[Condition] = RW.enumeration(List(Must, MustNot, Filter, Should)) + case object Must extends Condition case object MustNot extends Condition case object Filter extends Condition diff --git a/core/src/main/scala/lightdb/filter/Filter.scala b/core/src/main/scala/lightdb/filter/Filter.scala index 222653b7..bc0d1b4f 100644 --- a/core/src/main/scala/lightdb/filter/Filter.scala +++ b/core/src/main/scala/lightdb/filter/Filter.scala @@ -1,6 +1,6 @@ package lightdb.filter -import fabric.Json +import fabric.{Json, Str} import lightdb.Field import lightdb.doc.{Document, DocumentModel} import lightdb.spatial.GeoPoint @@ -36,6 +36,16 @@ object Filter { def apply[Doc <: Document[Doc], F](field: Field[Doc, F], value: F): NotEquals[Doc, F] = NotEquals(field.name, value) } + case class Regex[Doc <: Document[Doc], F](fieldName: String, expression: String) extends Filter[Doc] { + def getJson(model: DocumentModel[Doc]): Json = Str(expression) + def field(model: DocumentModel[Doc]): Field[Doc, F] = model.fieldByName(fieldName) + + override lazy val fieldNames: List[String] = List(fieldName) + } + object Regex { + def apply[Doc <: Document[Doc], F](field: Field[Doc, F], expression: String): Regex[Doc, F] = Regex(field.name, expression) + } + case class In[Doc <: Document[Doc], F](fieldName: String, values: Seq[F]) extends Filter[Doc] { def getJson(model: DocumentModel[Doc]): List[Json] = values.toList.map(model.fieldByName[F](fieldName).rw.read) def field(model: DocumentModel[Doc]): Field[Doc, F] = model.fieldByName(fieldName) diff --git a/core/src/main/scala/lightdb/filter/FilterSupport.scala b/core/src/main/scala/lightdb/filter/FilterSupport.scala index 185ec479..bb9048a8 100644 --- a/core/src/main/scala/lightdb/filter/FilterSupport.scala +++ b/core/src/main/scala/lightdb/filter/FilterSupport.scala @@ -20,6 +20,9 @@ trait FilterSupport[F, Doc, Filter] { def <(value: F)(implicit num: Numeric[F]): Filter = range(None, Some(value), includeTo = false) def <=(value: F)(implicit num: Numeric[F]): Filter = range(None, Some(value)) + def ~*(expression: String): Filter = regex(expression) + def regex(expression: String): Filter + def BETWEEN(tuple: (F, F))(implicit num: Numeric[F]): Filter = range(Some(tuple._1), Some(tuple._2)) def <=>(tuple: (F, F))(implicit num: Numeric[F]): Filter = range(Some(tuple._1), Some(tuple._2)) diff --git a/core/src/test/scala/spec/AbstractBasicSpec.scala b/core/src/test/scala/spec/AbstractBasicSpec.scala index 4782158b..224bac9d 100644 --- a/core/src/test/scala/spec/AbstractBasicSpec.scala +++ b/core/src/test/scala/spec/AbstractBasicSpec.scala @@ -268,6 +268,20 @@ abstract class AbstractBasicSpec extends AnyWordSpec with Matchers { spec => people.map(_.name) should be(List("Veronica")) } } + "query name with regex match" in { + db.people.transaction { implicit transaction => + val people = db.people.query.filter(_.name ~* "Han.+").toList + people.map(_.name) should be(List("Hanna")) + } + } + "query nicknames with regex match" in { + db.people.transaction { implicit transaction => + val people = db.people.query + .filter(_.nicknames ~* ".+chy") + .toList + people.map(_.name) should be(List("Oscar")) + } + } "query with single-value, multiple nicknames" in { db.people.transaction { implicit transaction => val people = db.people.query diff --git a/lucene/src/main/scala/lightdb/lucene/LuceneStore.scala b/lucene/src/main/scala/lightdb/lucene/LuceneStore.scala index 8ab77037..ad2d0836 100644 --- a/lucene/src/main/scala/lightdb/lucene/LuceneStore.scala +++ b/lucene/src/main/scala/lightdb/lucene/LuceneStore.scala @@ -17,7 +17,7 @@ import lightdb.store.{Conversion, Store, StoreManager, StoreMode} import lightdb.transaction.Transaction import lightdb.util.Aggregator import org.apache.lucene.document.{DoubleField, DoublePoint, IntField, IntPoint, LatLonDocValuesField, LatLonPoint, LongField, LongPoint, NumericDocValuesField, SortedDocValuesField, SortedNumericDocValuesField, StoredField, StringField, TextField, Document => LuceneDocument, Field => LuceneField} -import org.apache.lucene.search.{BooleanClause, BooleanQuery, BoostQuery, FieldExistsQuery, IndexSearcher, MatchAllDocsQuery, ScoreDoc, SearcherFactory, SearcherManager, SortField, SortedNumericSortField, TermQuery, TopFieldCollector, TopFieldCollectorManager, TopFieldDocs, Query => LuceneQuery, Sort => LuceneSort} +import org.apache.lucene.search.{BooleanClause, BooleanQuery, BoostQuery, FieldExistsQuery, IndexSearcher, MatchAllDocsQuery, RegexpQuery, ScoreDoc, SearcherFactory, SearcherManager, SortField, SortedNumericSortField, TermQuery, TopFieldCollector, TopFieldCollectorManager, TopFieldDocs, Query => LuceneQuery, Sort => LuceneSort} import org.apache.lucene.index.{StoredFields, Term} import org.apache.lucene.queryparser.classic.QueryParser import org.apache.lucene.util.BytesRef @@ -242,6 +242,7 @@ class LuceneStore[Doc <: Document[Doc], Model <: DocumentModel[Doc]](directory: b.add(new MatchAllDocsQuery, BooleanClause.Occur.MUST) b.add(exactQuery(f.field(collection.model), f.getJson(collection.model)), BooleanClause.Occur.MUST_NOT) b.build() + case f: Filter.Regex[Doc, _] => new RegexpQuery(new Term(f.fieldName, f.expression)) case f: Filter.In[Doc, _] => val queries = f.getJson(collection.model).map(json => exactQuery(f.field(collection.model), json)) val b = new BooleanQuery.Builder diff --git a/sql/src/main/scala/lightdb/sql/SQLStore.scala b/sql/src/main/scala/lightdb/sql/SQLStore.scala index b00f2468..1bb3501e 100644 --- a/sql/src/main/scala/lightdb/sql/SQLStore.scala +++ b/sql/src/main/scala/lightdb/sql/SQLStore.scala @@ -499,6 +499,7 @@ abstract class SQLStore[Doc <: Document[Doc], Model <: DocumentModel[Doc]] exten SQLPart.merge(parts: _*) case f: Filter.NotEquals[Doc, _] if f.value == null | f.value == None => SQLPart(s"${f.fieldName} IS NOT NULL") case f: Filter.NotEquals[Doc, _] => SQLPart(s"${f.fieldName} != ?", List(SQLArg.FieldArg(f.field(collection.model), f.value))) + case f: Filter.Regex[Doc, _] => SQLPart(s"${f.fieldName} REGEXP ?", List(SQLArg.StringArg(f.expression))) case f: Filter.In[Doc, _] => SQLPart(s"${f.fieldName} IN (${f.values.map(_ => "?").mkString(", ")})", f.values.toList.map(v => SQLArg.FieldArg(f.field(collection.model), v))) case f: Filter.RangeLong[Doc] => (f.from, f.to) match { case (Some(from), Some(to)) => SQLPart(s"${f.fieldName} BETWEEN ? AND ?", List(SQLArg.LongArg(from), SQLArg.LongArg(to))) @@ -557,6 +558,7 @@ abstract class SQLStore[Doc <: Document[Doc], Model <: DocumentModel[Doc]] exten private def af2Part(f: AggregateFilter[Doc]): SQLPart = f match { case f: AggregateFilter.Equals[Doc, _] => SQLPart(s"${f.name} = ?", List(SQLArg.FieldArg(f.field, f.value))) case f: AggregateFilter.NotEquals[Doc, _] => SQLPart(s"${f.name} != ?", List(SQLArg.FieldArg(f.field, f.value))) + case f: AggregateFilter.Regex[Doc, _] => SQLPart(s"${f.name} REGEXP ?", List(SQLArg.StringArg(f.expression))) case f: AggregateFilter.In[Doc, _] => SQLPart(s"${f.name} IN (${f.values.map(_ => "?").mkString(", ")})", f.values.toList.map(v => SQLArg.FieldArg(f.field, v))) case f: AggregateFilter.Combined[Doc] => val parts = f.filters.map(f => af2Part(f)) diff --git a/sqlite/src/main/scala/lightdb/sql/SQLiteStore.scala b/sqlite/src/main/scala/lightdb/sql/SQLiteStore.scala index 623d3250..e8aa9f0d 100644 --- a/sqlite/src/main/scala/lightdb/sql/SQLiteStore.scala +++ b/sqlite/src/main/scala/lightdb/sql/SQLiteStore.scala @@ -16,6 +16,7 @@ import org.sqlite.SQLiteConfig.{JournalMode, LockingMode, SynchronousMode, Trans import java.io.File import java.nio.file.{Files, Path, StandardCopyOption} import java.sql.Connection +import java.util.regex.Pattern class SQLiteStore[Doc <: Document[Doc], Model <: DocumentModel[Doc]](val connectionManager: ConnectionManager, val connectionShared: Boolean, @@ -24,15 +25,23 @@ class SQLiteStore[Doc <: Document[Doc], Model <: DocumentModel[Doc]](val connect private val OptPointRegex = """\[POINT\((.+) (.+)\)\]""".r override protected def initTransaction()(implicit transaction: Transaction[Doc]): Unit = { + val c = connectionManager.getConnection if (hasSpatial) { scribe.info(s"${collection.name} has spatial features. Enabling...") - val c = connectionManager.getConnection val s = c.createStatement() s.executeUpdate(s"SELECT load_extension('${SQLiteStore.spatialitePath}');") val hasGeometryColumns = this.tables(c).contains("geometry_columns") if (!hasGeometryColumns) s.executeUpdate("SELECT InitSpatialMetaData()") s.close() } + org.sqlite.Function.create(c, "REGEXP", new org.sqlite.Function() { + override def xFunc(): Unit = { + val expression = value_text(0) + val value = Option(value_text(1)).getOrElse("") + val pattern = Pattern.compile(expression) + result(if (pattern.matcher(value).find()) 1 else 0) + } + }) super.initTransaction() }