CLOVER🍀

That was when it all began.

LuceneのCollectorとページング

LuceneのIndexSearcherのsearchメソッドに、Collectorという抽象クラスを取るものがあるのですが、こちらの書籍では「低レベルAPI」的な扱いをされていたので(正確には、その前身のHitCollectorが、ですが)なんとなく飛ばしていました。

Apache Lucene 入門 ~Java・オープンソース・全文検索システムの構築

Apache Lucene 入門 ~Java・オープンソース・全文検索システムの構築

が、searchメソッドの実装とかSolrでの使い方を見ていると、なんとなく無視してちゃダメじゃない?的な気がしてきて、ちょっと使ってみることにしました。

あと、このページで使っているのを見たことも、きっかけですね。

Lucene in 5 minutes
http://www.lucenetutorial.com/lucene-in-5-minutes.html

では、とりあえず、環境準備を。

build.sbt

name := "lucene-collector"

version := "0.0.1-SNAPSHOT"

scalaVersion := "2.10.2"

organization := "littlewings"

libraryDependencies ++= Seq(
  "org.apache.lucene" % "lucene-core" % "4.3.1",
  "org.apache.lucene" % "lucene-analyzers-kuromoji" % "4.3.1",
  "org.apache.lucene" % "lucene-queryparser" % "4.3.1"
)

libraryDependencies += "net.sf.supercsv" % "super-csv" % "2.1.0"

データは、こちらで作ったプログラムと同様
http://d.hatena.ne.jp/Kazuhira/20130622/1371901567

住所.jpから引っ張ってきています。

住所.jp
http://jusyo.jp/csv/new.php

データの仕様
http://jusyo.jp/csv/document.html

動作的には、前と作ったプログラムと同様にデータをインデクシング後、そのままクエリを投げられるものにしています。CSVの解析には、今回はSuperCSVを使っています。

src/main/scala/LuceneCollector.scala

import scala.collection.JavaConverters._
import scala.util.{Failure, Try}

import java.nio.charset.Charset
import java.nio.file.{Files, Paths}

import org.apache.lucene.analysis.Analyzer
import org.apache.lucene.analysis.ja.JapaneseAnalyzer
import org.apache.lucene.document.{Document, Field, StringField, TextField}
import org.apache.lucene.index.{DirectoryReader, IndexWriter, IndexWriterConfig}
import org.apache.lucene.queryparser.classic.QueryParser
import org.apache.lucene.search.{IndexSearcher, Query, Sort, SortField, TopDocs}
import org.apache.lucene.search.{TopFieldCollector, TotalHitCountCollector, TopScoreDocCollector}
import org.apache.lucene.store.{Directory, RAMDirectory}
import org.apache.lucene.util.Version

import org.supercsv.io.CsvListReader
import org.supercsv.prefs.CsvPreference

import LuceneCollector.AutoCloseableWrapper

object LuceneCollector {
  def main(args: Array[String]): Unit = {
    val directory = new RAMDirectory
    val luceneVersion = Version.LUCENE_43
    
    val indexer = new Indexer(directory,
                              luceneVersion,
                              new JapaneseAnalyzer(luceneVersion),
                              "zenkoku.csv")
    indexer.execute()

    InteractiveQuery.queryWhile(directory, luceneVersion, new JapaneseAnalyzer(luceneVersion))
  }

  implicit class AutoCloseableWrapper[A <: AutoCloseable](val underlying: A) extends AnyVal {
    def foreach(f: A => Unit): Unit =
      try {
        f(underlying)
      } finally {
        if (underlying != null) {
          underlying.close()
        }
      }
  }
}

class Indexer(directory: Directory, luceneVersion: Version, analyzer: Analyzer, source: String) {
  def execute(): Unit = {
    for {
      reader <- Files.newBufferedReader(Paths.get(source), Charset.forName("Windows-31J"))
      csvReader <- new CsvListReader(reader, CsvPreference.STANDARD_PREFERENCE)
      indexWriter <- new IndexWriter(directory,
                                     new IndexWriterConfig(luceneVersion,
                                                           analyzer))
    } {
      val count =
        Iterator
          .continually(csvReader.read())
          .takeWhile(_ != null)
          .foldLeft(0) { (acc, tokens) =>
            if (acc > 0 && acc % 10000 == 0) {
              printf("%1$,3d件…%n", acc)
            }

            indexWriter.addDocument(Address(tokens.asScala).toDocument)

            acc + 1
          }

      printf("%1$,3d件、インデックスに登録しました%n", count)
    }
  }
}

case class Address(tokens: Seq[String]) {
  def toDocument: Document = {
    val doc = new Document
    doc.add(stringField("addressCd", 0))
    doc.add(stringField("zipNo", 4))
    doc.add(textField("prefecture", 7))
    doc.add(textField("prefectureKana", 8))
    doc.add(textField("city", 9))
    doc.add(textField("cityKana", 10))
    doc.add(textField("town", 11))
    doc.add(textField("townKana", 12))
    doc.add(textField("azachome", 15))
    doc.add(textField("azachomeKana", 16))
    doc
  }
  private def stringField(name: String, index: Int): Field =
    new StringField(name, Option(tokens(index)).getOrElse(""), Field.Store.YES)

  private def textField(name: String, index: Int): Field =
    new TextField(name, Option(tokens(index)).getOrElse(""), Field.Store.YES)
}

object InteractiveQuery {
  private var currentPage: Int = 1
  private var offset: Int = 20
  private val max: Int = 2000000

  def queryWhile(directory: Directory, luceneVersion: Version, analyzer: Analyzer): Unit = {
    println("Start Interactive Query")

    for (reader <- DirectoryReader.open(directory)) {
      val searcher = new IndexSearcher(reader)

      val qp: QueryParser = new QueryParser(luceneVersion, "prefecture", analyzer)
      // qp.setDefaultOperator(QueryParser.Operator.AND)

      val query = (queryString: String) =>
        Try(qp.parse(queryString))
          .recoverWith { case th =>
                         println(s"[ERROR] Invalid Query: $th")
                         Failure(th)
                       }.toOption

      val pageRegex = """page\s*=\s*(\d+)""".r
      val changePaging: PartialFunction[String, Unit] = {
        case pageRegex(page) =>
          currentPage = Try(page.toInt).recover { case _ => 0 }.get
          println(s"set currentPage = $currentPage")
      }

      val offsetRegex = """offset\s*=\s*(\d+)""".r
      val changeOffset: PartialFunction[String, Unit] = {
        case offsetRegex(o) =>
          offset = Try(o.toInt).recover { case _ => 20 }.get
          println(s"set offset = $offset")
      }

      val executeQuery: PartialFunction[String, Unit] = {
        case line =>
          query(line).foreach { q =>
            println(s"入力したクエリ => $q")
            /** Collectorを使った検索処理と結果表示処理 */
          }
        }

      Iterator
        .continually(readLine("Lucene Query> "))
        .takeWhile(_ != "exit")
        .withFilter(line => !line.isEmpty && !line.endsWith("\\c"))
        .foreach(changePaging orElse changeOffset orElse executeQuery) 
    }
  }
}

で、この部分

      val executeQuery: PartialFunction[String, Unit] = {
        case line =>
          query(line).foreach { q =>
            println(s"入力したクエリ => $q")
            /** Collectorを使った検索処理と結果表示処理 */
          }
        }

をCollectorを使って埋めていこうという感じですね。

Collectorを使ってみる

今回は、最も簡単そうなTotalHitCountCollector、TopScoreDocCollector、TopFieldCollectorを使ってみます。

いずれのCollectorも、staticメソッドのcreateメソッドでインスタンスを作成するみたいです。実際には、サブクラスが返ってきているみたいですが。

では、まずTotalHitCountCollectorクラスを使ってみましょう。こちらは名前の通り、クエリのヒット件数を返すためのCollectorです。

// ヒット件数の取得
val totalHitCountCollector = new TotalHitCountCollector

searcher.search(q, totalHitCountCollector)

val totalHits = totalHitCountCollector.getTotalHits

これで、ヒット件数が取得できます。ドキュメントのデータは、取得できませんが。

続いて、普通に結果をスコア順で取得する場合に使う、TopScoreDocCollectorクラスです。createメソッドの第1引数は、Collectorを使わない場合にIndexSearcher#searchメソッドに渡していた、結果の取得件数と同じですね。

// 通常の、スコア順での検索
val docCollector =
  TopScoreDocCollector.create(100000, true)

searcher.search(q, docCollector)

val topDocs = docCollector.topDocs
val hits = topDocs.scoreDocs

そして、特定のフィールドなどでソートを行う場合は、TopFieldCollectorクラスを使用します。

が、このクラスには注意書きがあって

WARNING: This API is experimental and might change in incompatible ways in the next release.

http://lucene.apache.org/core/4_3_1/core/org/apache/lucene/search/TopFieldCollector.html

このAPIは実験的機能だからね!次のリリースで非互換の変更が入るかもね!と言ってくれていますが、IndexSearcherの内部では普通に使っています…。まあ、一応頭に入れておきますか。

あ、今回使っているLuceneバージョンは、4.3.1ですから。

では、使用例。

// ソート有りの検索
val docCollector =
  TopFieldCollector.create(new Sort(new SortField("addressCd",
                                                     SortField.Type.STRING,
                                                     true),
                                       new SortField("zipNo",
                                                     SortField.Type.STRING,
                                                     true)),
                                        100000,
                                        true,  // fillFields
                                        false,  // trackDocScores
                                        false,  // trackMaxScore
                                        false)  // docScoredInOrder

searcher.search(q, docCollector)

val topDocs = docCollector.topDocs  // ここ、追加!!
val hits = topDocs.scoreDocs

そんなにTopScoreDocCollectorクラスと変わらない気もしますが、createメソッドで指定できる引数にスコア計算をするかどうかとかの指定ができます。

Collectorを使って、ページングを考えてみる

このコードを書いていて、ちょっとやってみたくなったのでページングもやってみることにしました。

まずは、Collectorの機能を使わないページングの方法を考えてみます。

まあ、IndexSearcher#searchで引数を調整しても同じですね。それから、今回はデータの取得にはTopFieldCollectorクラスを使用します。

今回のプログラムのクエリを投げる部分には、こっそりこんな定義を書いておきました。

object InteractiveQuery {
  private var currentPage: Int = 1
  private var offset: Int = 20
  private val max: Int = 2000000

それを変更するPartialFunctionと

val pageRegex = """page\s*=\s*(\d+)""".r
val changePaging: PartialFunction[String, Unit] = {
  case pageRegex(page) =>
    currentPage = Try(page.toInt).recover { case _ => 0 }.get
    println(s"set currentPage = $currentPage")
}

val offsetRegex = """offset\s*=\s*(\d+)""".r
val changeOffset: PartialFunction[String, Unit] = {
  case offsetRegex(o) =>
    offset = Try(o.toInt).recover { case _ => 20 }.get
    println(s"set offset = $offset")
}

クエリを実行するPartialFunctionを用意して、最後に合成するというアプローチです。

Iterator
  .continually(readLine("Lucene Query> "))
  .takeWhile(_ != "exit")
  .withFilter(line => !line.isEmpty && !line.endsWith("\\c"))
  .foreach(changePaging orElse changeOffset orElse executeQuery)

はい。

で、クエリを実行するPartialFunctionをこういう定義にしてみます。

val executeQuery: PartialFunction[String, Unit] = {
  case line =>
    query(line).foreach { q =>
      println(s"入力したクエリ => $q")

      // ヒット件数の取得
      val totalHitCountCollector = new TotalHitCountCollector

      searcher.search(q, totalHitCountCollector)

      val totalHits = totalHitCountCollector.getTotalHits

      printf("%1$,3d件、ヒットしました%n", totalHits)

      val start = (currentPage - 1) * offset
      if (totalHits > start) {
        // ソート有りの検索
        val docCollector =
          TopFieldCollector.create(new Sort(new SortField("addressCd",
                                                             SortField.Type.STRING,
                                                             true),
                                               new SortField("zipNo",
                                                             SortField.Type.STRING,
                                                             true)),
                                      currentPage * offset,
                                      true,  // fillFields
                                      false,  // trackDocScores
                                      false,  // trackMaxScore
                                      false)  // docScoredInOrder

        searcher.search(q, docCollector)

        val end =
          if ((currentPage * offset) > totalHits) totalHits
          else currentPage * offset

        val topDocs = docCollector.topDocs
        val hits = topDocs.scoreDocs

        printf("ヒットレコード中の、%1$,3d〜%2$,3d件までを表示します%n", start + 1, end)

        (start until end).foreach { i =>
          val h = hits(i)
          val hitDoc = searcher.doc(h.doc)
          println { s"Score,N[${h.score}:${h.doc}] : Doc => " +
                    hitDoc
                      .getFields
                      .asScala
                      .map(_.stringValue)
                      .mkString("  ", " | ", "")
                  }
        }
      } else {
        printf("開始位置が%1$,3dのため、表示するレコードがありません%n", start + 1)
      }
    }
  }

ちょっと、ページングっぽい感じですね。

どの範囲からDocumentを取得するのかは、自分で計算しています。

        (start until end).foreach { i =>
          val h = hits(i)

これを実行すると

> run
[info] Running LuceneCollector 
10,000件…
20,000件…
30,000件…
40,000件…
50,000件…
60,000件…
70,000件…
80,000件…
90,000件…
100,000件…
110,000件…
120,000件…
130,000件…
140,000件…
148,204件、インデックスに登録しました
Start Interactive Query
Lucene Query> 

とまあ、クエリ待ちの状態になります。

クエリを投げると、ヒットした場合は1ページ中に表示可能な件数が表示されます。

Lucene Query> 福岡
入力したクエリ => prefecture:福岡
4,136件、ヒットしました
ヒットレコード中の、  1〜 20件までを表示します
Score,N[NaN:136970] : Doc =>   871858500 | 871-8585 | 福岡県 | フクオカケン | 築上郡吉富町 | チクジョウグンヨシトミマチ | 広津 | ヒロツ |  | 
Score,N[NaN:136962] : Doc =>   871855000 | 871-8550 | 福岡県 | フクオカケン | 築上郡吉富町 | チクジョウグンヨシトミマチ | 小祝 | コイワイ |  | 
Score,N[NaN:136989] : Doc =>   871099300 | 871-0993 | 福岡県 | フクオカケン | 築上郡上毛町 | チクジョウグンコウゲマチ | 東下 | ヒガシシモ |  | 

*結果は、省略しています。

ページ位置や、表示件数を変更したければ用意したPartialFunctionで。

Lucene Query> page = 2
set currentPage = 2
Lucene Query> offset = 40
set offset = 40
Lucene Query> 福岡
入力したクエリ => prefecture:福岡
4,136件、ヒットしました
ヒットレコード中の、 41〜 80件までを表示します
Score,N[NaN:134948] : Doc =>   839850200 | 839-8502 | 福岡県 | フクオカケン | 久留米市 | クルメシ | 御井町 | ミイマチ |  | 
Score,N[NaN:134749] : Doc =>   839850100 | 839-8501 | 福岡県 | フクオカケン | 久留米市 | クルメシ | 合川町 | アイカワマチ |  | 

最初、ちょっと参考にしたページ。
http://stackoverflow.com/questions/351176/paging-lucenes-search-results
http://stackoverflow.com/questions/963781/how-to-achieve-pagination-in-lucene

で、今度はCollectorのメソッドを使って、同じことをやってみます。

これには、TopFieldCollectorクラスやTopScoreDocCollectorクラスの親クラスである、TopDocsCollectorクラスの引数があるtopDocsメソッドを使用すればよさそうです。

今度は、こんな感じに。

val executeQuery: PartialFunction[String, Unit] = {
  case line =>
    query(line).foreach { q =>
      println(s"入力したクエリ => $q")

      // ヒット件数の取得
      val totalHitCountCollector = new TotalHitCountCollector

      searcher.search(q, totalHitCountCollector)

      val totalHits = totalHitCountCollector.getTotalHits

      printf("%1$,3d件、ヒットしました%n", totalHits)

      val start = (currentPage - 1) * offset
      if (totalHits > start) {
        // ソート有りの検索
        val docCollector =
          TopFieldCollector.create(new Sort(new SortField("addressCd",
                                                             SortField.Type.STRING,
                                                             true),
                                               new SortField("zipNo",
                                                             SortField.Type.STRING,
                                                             true)),
                                      currentPage * offset,
                                      true,  // fillFields
                                      false,  // trackDocScores
                                      false,  // trackMaxScore
                                      false)  // docScoredInOrder

        searcher.search(q, docCollector)

        val end =
          if ((currentPage * offset) > totalHits) totalHits
          else currentPage * offset

        val topDocs = docCollector.topDocs(start, currentPage * offset)
        val hits = topDocs.scoreDocs

        printf("ヒットレコード中の、%1$,3d〜%2$,3d件までを表示します%n", start + 1, end)

        hits foreach { h =>
          val hitDoc = searcher.doc(h.doc)
          println { s"Score,N[${h.score}:${h.doc}] : Doc => " +
                    hitDoc
                      .getFields
                      .asScala
                      .map(_.stringValue)
                      .mkString("  ", " | ", "")
                  }
        }
      } else {
        printf("開始位置が%1$,3dのため、表示するレコードがありません%n", start + 1)
      }
    }
  }

範囲の指定は、これだけになってスッキリ。

        val topDocs = docCollector.topDocs(start, currentPage * offset)

動作は変わらないので、割愛します。

topDocsメソッドには、他にも開始位置のみを指定できる版もあります。

これらの引数を取るtopDocsメソッドの注意事項としては、

NOTE: you cannot call this method more than once for each search execution. If you need to call it more than once, passing each time a different range, you should call topDocs() and work with the returned TopDocs object, which will contain all the results this search execution collected.

基本的には使い捨てということ。複数の範囲を指定したい場合は、引数を取らないtopDocsメソッドを使いなさいということらしいです。

とりあえず、Collectorの簡単な使い方はわかった気がします。

最後に、今回作成したコード全体を載せておきます。一部、コメントアウトも入ってます。
src/main/scala/LuceneCollector.scala

import scala.collection.JavaConverters._
import scala.util.{Failure, Try}

import java.nio.charset.Charset
import java.nio.file.{Files, Paths}

import org.apache.lucene.analysis.Analyzer
import org.apache.lucene.analysis.ja.JapaneseAnalyzer
import org.apache.lucene.document.{Document, Field, StringField, TextField}
import org.apache.lucene.index.{DirectoryReader, IndexWriter, IndexWriterConfig}
import org.apache.lucene.queryparser.classic.QueryParser
import org.apache.lucene.search.{IndexSearcher, Query, Sort, SortField, TopDocs}
import org.apache.lucene.search.{TopFieldCollector, TotalHitCountCollector, TopScoreDocCollector}
import org.apache.lucene.store.{Directory, RAMDirectory}
import org.apache.lucene.util.Version

import org.supercsv.io.CsvListReader
import org.supercsv.prefs.CsvPreference

import LuceneCollector.AutoCloseableWrapper

object LuceneCollector {
  def main(args: Array[String]): Unit = {
    val directory = new RAMDirectory
    val luceneVersion = Version.LUCENE_43
    
    val indexer = new Indexer(directory,
                              luceneVersion,
                              new JapaneseAnalyzer(luceneVersion),
                              "zenkoku.csv")
    indexer.execute()

    InteractiveQuery.queryWhile(directory, luceneVersion, new JapaneseAnalyzer(luceneVersion))
  }

  implicit class AutoCloseableWrapper[A <: AutoCloseable](val underlying: A) extends AnyVal {
    def foreach(f: A => Unit): Unit =
      try {
        f(underlying)
      } finally {
        if (underlying != null) {
          underlying.close()
        }
      }
  }
}

class Indexer(directory: Directory, luceneVersion: Version, analyzer: Analyzer, source: String) {
  def execute(): Unit = {
    for {
      reader <- Files.newBufferedReader(Paths.get(source), Charset.forName("Windows-31J"))
      csvReader <- new CsvListReader(reader, CsvPreference.STANDARD_PREFERENCE)
      indexWriter <- new IndexWriter(directory,
                                     new IndexWriterConfig(luceneVersion,
                                                           analyzer))
    } {
      val count =
        Iterator
          .continually(csvReader.read())
          .takeWhile(_ != null)
          .foldLeft(0) { (acc, tokens) =>
            if (acc > 0 && acc % 10000 == 0) {
              printf("%1$,3d件…%n", acc)
            }

            indexWriter.addDocument(Address(tokens.asScala).toDocument)

            acc + 1
          }

      printf("%1$,3d件、インデックスに登録しました%n", count)
    }
  }
}

case class Address(tokens: Seq[String]) {
  def toDocument: Document = {
    val doc = new Document
    doc.add(stringField("addressCd", 0))
    doc.add(stringField("zipNo", 4))
    doc.add(textField("prefecture", 7))
    doc.add(textField("prefectureKana", 8))
    doc.add(textField("city", 9))
    doc.add(textField("cityKana", 10))
    doc.add(textField("town", 11))
    doc.add(textField("townKana", 12))
    doc.add(textField("azachome", 15))
    doc.add(textField("azachomeKana", 16))
    doc
  }
  private def stringField(name: String, index: Int): Field =
    new StringField(name, Option(tokens(index)).getOrElse(""), Field.Store.YES)

  private def textField(name: String, index: Int): Field =
    new TextField(name, Option(tokens(index)).getOrElse(""), Field.Store.YES)
}

object InteractiveQuery {
  private var currentPage: Int = 1
  private var offset: Int = 20
  private val max: Int = 2000000

  def queryWhile(directory: Directory, luceneVersion: Version, analyzer: Analyzer): Unit = {
    println("Start Interactive Query")

    for (reader <- DirectoryReader.open(directory)) {
      val searcher = new IndexSearcher(reader)

      val qp: QueryParser = new QueryParser(luceneVersion, "prefecture", analyzer)
      // qp.setDefaultOperator(QueryParser.Operator.AND)

      val query = (queryString: String) =>
        Try(qp.parse(queryString))
          .recoverWith { case th =>
                         println(s"[ERROR] Invalid Query: $th")
                         Failure(th)
                       }.toOption

      val pageRegex = """page\s*=\s*(\d+)""".r
      val changePaging: PartialFunction[String, Unit] = {
        case pageRegex(page) =>
          currentPage = Try(page.toInt).recover { case _ => 0 }.get
          println(s"set currentPage = $currentPage")
      }

      val offsetRegex = """offset\s*=\s*(\d+)""".r
      val changeOffset: PartialFunction[String, Unit] = {
        case offsetRegex(o) =>
          offset = Try(o.toInt).recover { case _ => 20 }.get
          println(s"set offset = $offset")
      }

      val executeQuery: PartialFunction[String, Unit] = {
        case line =>
          query(line).foreach { q =>
            println(s"入力したクエリ => $q")

            // ヒット件数の取得
            val totalHitCountCollector = new TotalHitCountCollector

            searcher.search(q, totalHitCountCollector)

            val totalHits = totalHitCountCollector.getTotalHits

            printf("%1$,3d件、ヒットしました%n", totalHits)

            val start = (currentPage - 1) * offset
            if (totalHits > start) {
              // 通常の、スコア順での検索
              // val docCollector =
                // TopScoreDocCollector.create(currentPage * offset, true)

              // ソート有りの検索
              val docCollector =
                TopFieldCollector.create(new Sort(new SortField("addressCd",
                                                                   SortField.Type.STRING,
                                                                   true),
                                                     new SortField("zipNo",
                                                                   SortField.Type.STRING,
                                                                   true)),
                                            currentPage * offset,
                                            true,  // fillFields
                                            false,  // trackDocScores
                                            false,  // trackMaxScore
                                            false)  // docScoredInOrder

              searcher.search(q, docCollector)

              val end =
                if ((currentPage * offset) > totalHits) totalHits
                else currentPage * offset

              // val topDocs = docCollector.topDocs
              val topDocs = docCollector.topDocs(start, currentPage * offset)
              val hits = topDocs.scoreDocs

              printf("ヒットレコード中の、%1$,3d〜%2$,3d件までを表示します%n", start + 1, end)

              hits foreach { h =>
                val hitDoc = searcher.doc(h.doc)
                println { s"Score,N[${h.score}:${h.doc}] : Doc => " +
                          hitDoc
                            .getFields
                            .asScala
                            .map(_.stringValue)
                            .mkString("  ", " | ", "")
                        }
              }

              /*
              (start until end).foreach { i =>
                val h = hits(i)
                val hitDoc = searcher.doc(h.doc)
                println { s"Score,N[${h.score}:${h.doc}] : Doc => " +
                          hitDoc
                            .getFields
                            .asScala
                            .map(_.stringValue)
                            .mkString("  ", " | ", "")
                        }
              }
              */
            } else {
              printf("開始位置が%1$,3dのため、表示するレコードがありません%n", start + 1)
            }
          }
        }

      Iterator
        .continually(readLine("Lucene Query> "))
        .takeWhile(_ != "exit")
        .withFilter(line => !line.isEmpty && !line.endsWith("\\c"))
        .foreach(changePaging orElse changeOffset orElse executeQuery) 
    }
  }
}