From 7cf0e0459fa07a7b5ff2da5a6e4089cb11423372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sat, 2 Nov 2024 16:31:36 +0100 Subject: [PATCH 001/106] 24.11.02 fix broken tests right now on `sbt run test` it fails due to a misplaced file: [error] java.sql.SQLException: java.sql.SQLException: IO Error: No files found that match the pattern "/home/bbi/ticklish/tyql/bench/data/tc/edge.csv" [error] at org.duckdb.DuckDBPreparedStatement.prepare(DuckDBPreparedStatement.java:121) [error] at org.duckdb.DuckDBPreparedStatement.execute(DuckDBPreparedStatement.java:195) [error] at tyql.main$package$.main(main.scala:57) [error] at tyql.main.main(main.scala:39) [error] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) [error] at java.base/java.lang.reflect.Method.invoke(Method.java:580) [error] Caused by: java.sql.SQLException: IO Error: No files found that match the pattern "/home/bbi/ticklish/tyql/bench/data/tc/edge.csv" [error] at org.duckdb.DuckDBNative.duckdb_jdbc_prepare(Native Method) [error] at org.duckdb.DuckDBPreparedStatement.prepare(DuckDBPreparedStatement.java:115) [error] at org.duckdb.DuckDBPreparedStatement.execute(DuckDBPreparedStatement.java:195) [error] at tyql.main$package$.main(main.scala:57) [error] at tyql.main.main(main.scala:39) [error] at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) [error] at java.base/java.lang.reflect.Method.invoke(Method.java:580) [error] stack trace is suppressed; run last Compile / run for the full output [error] (Compile / run) java.sql.SQLException: java.sql.SQLException: IO Error: No files found that match the pattern "/home/bbi/ticklish/tyql/bench/data/tc/edge.csv" [error] Total time: 1 s, completed 2 Nov 2024, 16:30:15 --- src/main/scala/tyql/main.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/scala/tyql/main.scala b/src/main/scala/tyql/main.scala index 262c409..7242be3 100644 --- a/src/main/scala/tyql/main.scala +++ b/src/main/scala/tyql/main.scala @@ -54,7 +54,7 @@ object Edge extends ScalaSQLTable[EdgeSS] statement.execute(ddl) ) - statement.execute(s"COPY tc_edge FROM '${BuildInfo.baseDirectory}/bench/data/tc/edge.csv'") + statement.execute(s"COPY tc_edge FROM '${BuildInfo.baseDirectory}/bench/data/tc/data/edge.csv'") val resultSet: ResultSet = statement.executeQuery("SELECT * FROM tc_edge") @@ -87,4 +87,4 @@ object Edge extends ScalaSQLTable[EdgeSS] // ).distinct // )//.filter(p => p.x > 1).map(p => p.x) // -// println(s"fix=$result") \ No newline at end of file +// println(s"fix=$result") From c87539c1fa00ea7bb02b1365786109566ee25ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Wed, 13 Nov 2024 02:02:30 +0100 Subject: [PATCH 002/106] Docker instrumentation for DBs and tests --- .dockerignore | 7 ++++ .gitignore | 1 + Dockerfile | 39 +++++++++++++++++++++++ dev.sh | 22 +++++++++++++ dev.sh.completion | 15 +++++++++ docker-compose.yml | 79 ++++++++++++++++++++++++++++++++++++++++++++++ docker-notes.txt | 11 +++++++ start.sh | 23 ++++++++++++++ 8 files changed, 197 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100755 dev.sh create mode 100644 dev.sh.completion create mode 100644 docker-compose.yml create mode 100644 docker-notes.txt create mode 100755 start.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..27e42ae --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.dockerignore +.git +.gitignore +target/ +test-results/ +*.md +*.log diff --git a/.gitignore b/.gitignore index 1ed4c3f..0a4e285 100644 --- a/.gitignore +++ b/.gitignore @@ -75,3 +75,4 @@ cs # Coursier test product compiler/test-coursier/run/*.jar +/test-results/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fd327e0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +FROM debian:12 + +# Install all packages in one layer, including sbt repository setup +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + curl \ + unzip \ + netcat-openbsd \ + sqlite3 \ + ca-certificates \ + gnupg && \ + echo "deb https://repo.scala-sbt.org/scalasbt/debian all main" | tee /etc/apt/sources.list.d/sbt.list && \ + curl -sL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x2EE0EA64E40A89B84B2DF73499E82A75642AC823" | apt-key add && \ + curl -L https://download.oracle.com/java/21/latest/jdk-21_linux-x64_bin.deb -o jdk.deb && \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + ./jdk.deb \ + sbt && \ + rm jdk.deb && \ + rm -rf /var/lib/apt/lists/* + +# Install DuckDB +RUN curl -L https://github.com/duckdb/duckdb/releases/download/v0.8.1/duckdb_cli-linux-amd64.zip -o duckdb.zip \ + && unzip duckdb.zip \ + && mv duckdb /usr/local/bin/ \ + && chmod +x /usr/local/bin/duckdb \ + && rm duckdb.zip + +# Run as a non-root user +RUN useradd -m -s /bin/bash appuser \ + && mkdir -p /app /test-results \ + && chown -R appuser:appuser /app /test-results +COPY --chown=appuser:appuser start.sh /start.sh +RUN chmod +x /start.sh +WORKDIR /app +USER appuser + +CMD ["/start.sh"] + diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..acfca49 --- /dev/null +++ b/dev.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -euo pipefail + +command="$1" + +case $command in + "db-start") + docker-compose --profile dbs start + ;; + "db-stop") + docker-compose --profile dbs stop + ;; + "test") + mkdir -p test-results + docker-compose --profile dbs --profile tests up main + ;; + *) + echo "Unknown command: $command" + echo "Usage: ./dev.sh [up|down|test]" + exit 1 + ;; +esac diff --git a/dev.sh.completion b/dev.sh.completion new file mode 100644 index 0000000..deb63e6 --- /dev/null +++ b/dev.sh.completion @@ -0,0 +1,15 @@ +_dev_sh_complete() { + local cur prev commands + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="${COMP_WORDS[COMP_CWORD-1]}" + + commands="db-start db-stop test" + + if [[ ${COMP_CWORD} == 1 ]] ; then + COMPREPLY=( $(compgen -W "${commands}" -- ${cur}) ) + return 0 + fi +} + +complete -F _dev_sh_complete ./dev.sh diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3dce1bc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,79 @@ +version: '3.8' + +services: + main: + build: + context: . + dockerfile: Dockerfile + network: host + platform: linux/amd64 + network_mode: host + volumes: + - ./test-results:/test-results + - .:/app + depends_on: + postgres: + condition: service_healthy + mysql: + condition: service_healthy + mariadb: + condition: service_healthy + environment: + - POSTGRES_HOST=localhost + - POSTGRES_PORT=5433 + - MYSQL_HOST=localhost + - MYSQL_PORT=3307 + - MARIADB_HOST=localhost + - MARIADB_PORT=3308 + profiles: ["tests"] + + postgres: + image: postgres:15 + platform: linux/amd64 + environment: + POSTGRES_DB: testdb + POSTGRES_USER: testuser + POSTGRES_PASSWORD: testpass + ports: + - "5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U testuser -d testdb"] + interval: 5s + timeout: 5s + retries: 5 + profiles: ["dbs"] + + mysql: + image: mysql:8 + platform: linux/amd64 + environment: + MYSQL_DATABASE: testdb + MYSQL_USER: testuser + MYSQL_PASSWORD: testpass + MYSQL_ROOT_PASSWORD: rootpass + ports: + - "3307:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "testuser", "--password=testpass"] + interval: 5s + timeout: 5s + retries: 5 + profiles: ["dbs"] + + mariadb: + image: mariadb:10.11 + platform: linux/amd64 + environment: + MARIADB_DATABASE: testdb + MARIADB_USER: testuser + MARIADB_PASSWORD: testpass + MARIADB_ROOT_PASSWORD: rootpass + ports: + - "3308:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "testuser", "--password=testpass"] + interval: 5s + timeout: 5s + retries: 5 + profiles: ["dbs"] + diff --git a/docker-notes.txt b/docker-notes.txt new file mode 100644 index 0000000..9fdd5ec --- /dev/null +++ b/docker-notes.txt @@ -0,0 +1,11 @@ +commands to run FIRST TIME +$ docker-compose --profile dbs up -d # and ^C once the DBs stopped producing startup logs +# 74 seconds +$ docker-compose --profile dbs --profile tests up -d +# 45 seconds + +Then on further uses: +$ ./dev.sh db-start +$ ./dev.sh db-stop +$ ./dev.sh test # first time will install things for ~60s, after that it will just keep running the tests + diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..200ee0d --- /dev/null +++ b/start.sh @@ -0,0 +1,23 @@ +#!/bin/bash +wait_for_port() { + local service="$1" + local port="$2" + echo "Waiting for ${service}..." + while ! nc -z localhost "$port"; do + sleep 1 + done + echo "${service} at port ${port} ready" +} + +wait_for_port "PostgreSQL" 5433 +wait_for_port "MySQL" 3307 +wait_for_port "MariaDB" 3308 +echo "All databases ready" + +TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S) +TEST_OUTPUT_FILE="/test-results/test-output_${TIMESTAMP}.log" +sbt "test" < /dev/null 2>&1 | tee >(sed 's/\x1b\[[0-9;]*m//g' > "${TEST_OUTPUT_FILE}") + +EXIT_CODE=${PIPESTATUS[0]} +exit $EXIT_CODE + From a3334b8b4e95f1eb04baa0f7f2afca74dec9ea78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Wed, 13 Nov 2024 21:55:04 +0100 Subject: [PATCH 003/106] Check connection to the DBs --- .dockerignore | 6 +- Dockerfile | 8 +- build.sbt | 14 +-- .../test/integration/DBsResponding.scala | 92 +++++++++++++++++++ 4 files changed, 108 insertions(+), 12 deletions(-) create mode 100644 src/test/scala/test/integration/DBsResponding.scala diff --git a/.dockerignore b/.dockerignore index 27e42ae..61e3293 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,11 @@ .dockerignore -.git +.git/ .gitignore target/ +project/target/ test-results/ *.md *.log +.bloop/ +.metals/ +.bsp/ diff --git a/Dockerfile b/Dockerfile index fd327e0..8bdc3c9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,10 +30,14 @@ RUN curl -L https://github.com/duckdb/duckdb/releases/download/v0.8.1/duckdb_cli RUN useradd -m -s /bin/bash appuser \ && mkdir -p /app /test-results \ && chown -R appuser:appuser /app /test-results + +USER appuser +WORKDIR /app +COPY --chown=appuser:appuser build.sbt ./ +COPY --chown=appuser:appuser project ./project +RUN sbt update COPY --chown=appuser:appuser start.sh /start.sh RUN chmod +x /start.sh -WORKDIR /app -USER appuser CMD ["/start.sh"] diff --git a/build.sbt b/build.sbt index 843f48f..b91d130 100644 --- a/build.sbt +++ b/build.sbt @@ -6,7 +6,11 @@ inThisBuild(Seq( version := "0.0.1", libraryDependencies ++= Seq( "org.scalameta" %% "munit" % "1.0.0+24-ee555b1d-SNAPSHOT" % Test, - "org.duckdb" % "duckdb_jdbc" % "1.1.1", + "org.postgresql" % "postgresql" % "42.7.4", + "mysql" % "mysql-connector-java" % "8.0.33", + "org.mariadb.jdbc" % "mariadb-java-client" % "3.5.0", + "org.xerial" % "sqlite-jdbc" % "3.47.0.0", + "org.duckdb" % "duckdb_jdbc" % "1.1.3", "com.lihaoyi" %% "scalasql" % "0.1.11" ) )) @@ -30,14 +34,6 @@ lazy val root = (project in file(".")) // Test / testOptions += Tests.Argument(TestFrameworks.MUnit, "-b") buildInfoKeys := Seq[BuildInfoKey](baseDirectory), buildInfoPackage := "buildinfo", -// cleanFiles ++= Seq( -// baseDirectory.value / "bench/data/ancestry/out/collections.csv", -// baseDirectory.value / "bench/data/ancestry/out/tyql.csv", -// baseDirectory.value / "bench/data/ancestry/out/scalasql.csv", -// baseDirectory.value / "bench/data/andersens/out/collections.csv", -// baseDirectory.value / "bench/data/andersens/out/tyql.csv", -// baseDirectory.value / "bench/data/andersens/out/scalasql.csv", -// ), cleanFiles ++= Seq("tc", "ancestry", "andersens", diff --git a/src/test/scala/test/integration/DBsResponding.scala b/src/test/scala/test/integration/DBsResponding.scala new file mode 100644 index 0000000..ceeffc3 --- /dev/null +++ b/src/test/scala/test/integration/DBsResponding.scala @@ -0,0 +1,92 @@ +package test.integration.dbsresponding + +import munit.FunSuite +import java.sql.{Connection, DriverManager} + +class DBsResponding extends FunSuite { + def withConnection[A](url: String, user: String = "", password: String = "")(f: Connection => A): A = { + var conn: Connection = null + try { + conn = DriverManager.getConnection(url, user, password) + f(conn) + } finally { + if (conn != null) conn.close() + } + } + + test("PostgreSQL responds") { + withConnection( + "jdbc:postgresql://localhost:5433/testdb", + "testuser", + "testpass" + ) { conn => + val stmt = conn.createStatement() + val rs = stmt.executeQuery("SELECT ARRAY[5,10,3]::integer[] as arr") + assert(rs.next()) + val arr = rs.getArray("arr").getArray().asInstanceOf[Array[Integer]] + assertEquals(arr.toList.map(_.toInt), List(5, 10, 3)) + } + } + + test("MySQL responds") { + withConnection( + "jdbc:mysql://localhost:3307/testdb", + "testuser", + "testpass" + ) { conn => + val stmt = conn.createStatement() + val rs = stmt.executeQuery( + """SELECT GROUP_CONCAT(n ORDER BY n SEPARATOR '-') as concat + FROM (SELECT 7 as n UNION SELECT 22 UNION SELECT 31) nums""" + ) + assert(rs.next()) + assertEquals(rs.getString("concat"), "7-22-31") + } + } + + test("MariaDB responds") { + withConnection( + "jdbc:mariadb://localhost:3308/testdb", + "testuser", + "testpass" + ) { conn => + val stmt = conn.createStatement() + val rs = stmt.executeQuery( + """SELECT seq FROM seq_1_to_4""" + ) + var result = List.empty[Int] + while (rs.next()) { + result = result :+ rs.getInt("seq") + } + assertEquals(result, List(1, 2, 3, 4)) + } + } + + test("SQLite responds with its unique json_each table-valued function") { + withConnection("jdbc:sqlite::memory:") { conn => + val stmt = conn.createStatement() + val rs = stmt.executeQuery( + """SELECT value FROM json_each('[5,55,3]') ORDER BY value""" + ) + var result = List.empty[Int] + while (rs.next()) { + result = result :+ rs.getInt("value") + } + assertEquals(result, List(3, 5, 55)) + } + } + + test("DuckDB responds") { + withConnection("jdbc:duckdb:") { conn => + val stmt = conn.createStatement() + val rs = stmt.executeQuery( + """SELECT * FROM generate_series(1, 7, 2) as g""" + ) + var result = List.empty[Int] + while (rs.next()) { + result = result :+ rs.getInt(1) + } + assertEquals(result, List(1, 3, 5, 7)) + } + } +} From 2a2bbbb1b9630e2da488868e41a4d05be0e0ea96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Fri, 15 Nov 2024 01:21:56 +0100 Subject: [PATCH 004/106] Testsuite separation is now via MUnit tags There is only one tag now, for expensive tests. Untagged tests are cheap. `sbt test` runs all tests. `sbt "testOnly -- --include-tags=Expensive"` runs only the expensive tests. `sbt "testOnly -- --exclude-tags=Expensive"` runs only the cheap tests. --- src/test/scala/test/Tags.scala | 5 +++++ src/test/scala/test/integration/DBsResponding.scala | 12 +++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) create mode 100644 src/test/scala/test/Tags.scala diff --git a/src/test/scala/test/Tags.scala b/src/test/scala/test/Tags.scala new file mode 100644 index 0000000..2ef4b30 --- /dev/null +++ b/src/test/scala/test/Tags.scala @@ -0,0 +1,5 @@ +package test + +import munit.Tag + +val expensiveTest = new munit.Tag("Expensive") diff --git a/src/test/scala/test/integration/DBsResponding.scala b/src/test/scala/test/integration/DBsResponding.scala index ceeffc3..bf6acf6 100644 --- a/src/test/scala/test/integration/DBsResponding.scala +++ b/src/test/scala/test/integration/DBsResponding.scala @@ -1,6 +1,8 @@ package test.integration.dbsresponding import munit.FunSuite +import test.expensiveTest + import java.sql.{Connection, DriverManager} class DBsResponding extends FunSuite { @@ -14,7 +16,7 @@ class DBsResponding extends FunSuite { } } - test("PostgreSQL responds") { + test("PostgreSQL responds".tag(expensiveTest)) { withConnection( "jdbc:postgresql://localhost:5433/testdb", "testuser", @@ -28,7 +30,7 @@ class DBsResponding extends FunSuite { } } - test("MySQL responds") { + test("MySQL responds".tag(expensiveTest)) { withConnection( "jdbc:mysql://localhost:3307/testdb", "testuser", @@ -44,7 +46,7 @@ class DBsResponding extends FunSuite { } } - test("MariaDB responds") { + test("MariaDB responds".tag(expensiveTest)) { withConnection( "jdbc:mariadb://localhost:3308/testdb", "testuser", @@ -62,7 +64,7 @@ class DBsResponding extends FunSuite { } } - test("SQLite responds with its unique json_each table-valued function") { + test("SQLite responds with its unique json_each table-valued function".tag(expensiveTest)) { withConnection("jdbc:sqlite::memory:") { conn => val stmt = conn.createStatement() val rs = stmt.executeQuery( @@ -76,7 +78,7 @@ class DBsResponding extends FunSuite { } } - test("DuckDB responds") { + test("DuckDB responds".tag(expensiveTest)) { withConnection("jdbc:duckdb:") { conn => val stmt = conn.createStatement() val rs = stmt.executeQuery( From 1d823b4d28581c98ce0d6569861037c94dec1fa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Fri, 15 Nov 2024 01:37:58 +0100 Subject: [PATCH 005/106] Also instument the H2 DB --- build.sbt | 1 + .../scala/test/integration/DBsResponding.scala | 14 +++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index b91d130..0f6e4dc 100644 --- a/build.sbt +++ b/build.sbt @@ -11,6 +11,7 @@ inThisBuild(Seq( "org.mariadb.jdbc" % "mariadb-java-client" % "3.5.0", "org.xerial" % "sqlite-jdbc" % "3.47.0.0", "org.duckdb" % "duckdb_jdbc" % "1.1.3", + "com.h2database" % "h2" % "2.3.232", "com.lihaoyi" %% "scalasql" % "0.1.11" ) )) diff --git a/src/test/scala/test/integration/DBsResponding.scala b/src/test/scala/test/integration/DBsResponding.scala index bf6acf6..67e04cc 100644 --- a/src/test/scala/test/integration/DBsResponding.scala +++ b/src/test/scala/test/integration/DBsResponding.scala @@ -64,7 +64,7 @@ class DBsResponding extends FunSuite { } } - test("SQLite responds with its unique json_each table-valued function".tag(expensiveTest)) { + test("SQLite responds".tag(expensiveTest)) { withConnection("jdbc:sqlite::memory:") { conn => val stmt = conn.createStatement() val rs = stmt.executeQuery( @@ -91,4 +91,16 @@ class DBsResponding extends FunSuite { assertEquals(result, List(1, 3, 5, 7)) } } + + test("H2 responds".tag(expensiveTest)) { + withConnection("jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1") { conn => + val stmt = conn.createStatement() + val rs = stmt.executeQuery( + """SELECT ARRAY_CONCAT(ARRAY[8, 0], ARRAY[1, 78]) as combined""" + ) + assert(rs.next()) + val arr = rs.getArray("combined").getArray().asInstanceOf[Array[Integer]] + assertEquals(arr.toList.map(_.toInt), List(8, 0, 1, 78)) + } + } } From 8d29f44135c281ff9e75e11b5cd261b8d4dc9595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Fri, 15 Nov 2024 01:39:34 +0100 Subject: [PATCH 006/106] test: rename integration test for clarity --- .../test/integration/{DBsResponding.scala => DBsAreLive.scala} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename src/test/scala/test/integration/{DBsResponding.scala => DBsAreLive.scala} (98%) diff --git a/src/test/scala/test/integration/DBsResponding.scala b/src/test/scala/test/integration/DBsAreLive.scala similarity index 98% rename from src/test/scala/test/integration/DBsResponding.scala rename to src/test/scala/test/integration/DBsAreLive.scala index 67e04cc..7899a53 100644 --- a/src/test/scala/test/integration/DBsResponding.scala +++ b/src/test/scala/test/integration/DBsAreLive.scala @@ -5,7 +5,7 @@ import test.expensiveTest import java.sql.{Connection, DriverManager} -class DBsResponding extends FunSuite { +class DBsAreLive extends FunSuite { def withConnection[A](url: String, user: String = "", password: String = "")(f: Connection => A): A = { var conn: Connection = null try { From 5c8b3dc1b164298da9b876c52c33c6e8e489a907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Fri, 15 Nov 2024 01:52:29 +0100 Subject: [PATCH 007/106] Development section of the README --- README.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ docker-notes.txt | 11 ----------- 2 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 README.md delete mode 100644 docker-notes.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..eb02202 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# tyql + +## Development +### Running Tests +Tests are untagged by default or tagged as expensive. +```scala +import test.expensiveTest +test("PostgreSQL responds".tag(expensiveTest)) { ??? } +``` + +```bash +# Run all tests (both cheap and expensive) +sbt test +# Run only expensive tests +sbt "testOnly -- --include-tags=Expensive" +# Run only cheap tests +sbt "testOnly -- --exclude-tags=Expensive" +``` + +## Containerization + +We provide a `dev.sh` script to manage the development environment using Docker Compose. +```bash +# Start all required databases +./dev.sh db-start +# Stop all databases +./dev.sh db-stop +# Run all tests +./dev.sh test +``` +For convenience, bash completion is provided for the `dev.sh` script. To enable it: +```bash +# Add this to your ~/.bashrc or ~/.bash_profile +source /path/to/project/dev.sh.completion +``` +After enabling completion, you can use Tab to autocomplete `dev.sh` commands: +```bash +./dev.sh +# Shows: db-start db-stop test +``` +Test results from Docker are automatically saved to the `test-results` directory with timestamps. + +The containerized environment includes: +- PostgreSQL (port 5433) +- MySQL (port 3307) +- MariaDB (port 3308) +- SQLite, DuckDB, H2 (in-memory from the main container) diff --git a/docker-notes.txt b/docker-notes.txt deleted file mode 100644 index 9fdd5ec..0000000 --- a/docker-notes.txt +++ /dev/null @@ -1,11 +0,0 @@ -commands to run FIRST TIME -$ docker-compose --profile dbs up -d # and ^C once the DBs stopped producing startup logs -# 74 seconds -$ docker-compose --profile dbs --profile tests up -d -# 45 seconds - -Then on further uses: -$ ./dev.sh db-start -$ ./dev.sh db-stop -$ ./dev.sh test # first time will install things for ~60s, after that it will just keep running the tests - From 2d39fdda84a0199d422fe052e0ba6952ff592b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sun, 17 Nov 2024 20:18:30 +0100 Subject: [PATCH 008/106] Better connection tests for H2 --- src/test/scala/test/integration/DBsAreLive.scala | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/test/scala/test/integration/DBsAreLive.scala b/src/test/scala/test/integration/DBsAreLive.scala index 7899a53..b15e28e 100644 --- a/src/test/scala/test/integration/DBsAreLive.scala +++ b/src/test/scala/test/integration/DBsAreLive.scala @@ -95,12 +95,14 @@ class DBsAreLive extends FunSuite { test("H2 responds".tag(expensiveTest)) { withConnection("jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1") { conn => val stmt = conn.createStatement() - val rs = stmt.executeQuery( - """SELECT ARRAY_CONCAT(ARRAY[8, 0], ARRAY[1, 78]) as combined""" - ) - assert(rs.next()) - val arr = rs.getArray("combined").getArray().asInstanceOf[Array[Integer]] - assertEquals(arr.toList.map(_.toInt), List(8, 0, 1, 78)) + + val rs1 = stmt.executeQuery("""SELECT CASEWHEN(1=1, 'yes', 'no') as result""") + assert(rs1.next()) + assertEquals(rs1.getString("result"), "yes") + + val rs2 = stmt.executeQuery("""SELECT DECODE(1, 1, 'one', 'other') as result""") + assert(rs2.next()) + assertEquals(rs2.getString("result"), "one") } } } From b9f73c2e26e880530a129eae69dd1331cb0a8ae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sun, 17 Nov 2024 22:50:05 +0100 Subject: [PATCH 009/106] Seal Ord --- src/main/scala/tyql/expr/Ord.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/scala/tyql/expr/Ord.scala b/src/main/scala/tyql/expr/Ord.scala index 279953c..c42b6be 100644 --- a/src/main/scala/tyql/expr/Ord.scala +++ b/src/main/scala/tyql/expr/Ord.scala @@ -3,7 +3,7 @@ package tyql import language.experimental.namedTuples import NamedTuple.{NamedTuple, AnyNamedTuple} -trait Ord +sealed trait Ord object Ord: case object ASC extends Ord case object DESC extends Ord From e1a085971acb6d9838c78ccc19fe82eb66ac90af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sun, 17 Nov 2024 22:56:19 +0100 Subject: [PATCH 010/106] Dialect specialization for string literals and quoting identifiers --- .../tyql/dialects/boolean literals.scala | 6 + src/main/scala/tyql/dialects/dialects.scala | 72 +++++ .../tyql/dialects/limit and offset.scala | 15 + .../tyql/dialects/quoting identifiers.scala | 266 ++++++++++++++++++ .../scala/tyql/dialects/string literals.scala | 77 +++++ src/main/scala/tyql/expr/Expr.scala | 1 + src/main/scala/tyql/ir/QueryIRNode.scala | 14 +- src/main/scala/tyql/ir/QueryIRTree.scala | 6 +- .../test/dialects/boolean literals.scala | 24 ++ .../test/dialects/dialect selection.scala | 44 +++ .../test/dialects/identifier quoting.scala | 66 +++++ .../test/dialects/limit and offset.scala | 72 +++++ .../scala/test/dialects/string literals.scala | 156 ++++++++++ .../scala/test/query/AggregationTests.scala | 3 +- src/test/scala/test/query/JoinTests.scala | 4 +- .../test/query/RecursiveBenchmarkTests.scala | 24 +- .../scala/test/query/RecursiveTests.scala | 2 +- src/test/scala/test/query/SubqueryTests.scala | 12 +- 18 files changed, 831 insertions(+), 33 deletions(-) create mode 100644 src/main/scala/tyql/dialects/boolean literals.scala create mode 100644 src/main/scala/tyql/dialects/dialects.scala create mode 100644 src/main/scala/tyql/dialects/limit and offset.scala create mode 100644 src/main/scala/tyql/dialects/quoting identifiers.scala create mode 100644 src/main/scala/tyql/dialects/string literals.scala create mode 100644 src/test/scala/test/dialects/boolean literals.scala create mode 100644 src/test/scala/test/dialects/dialect selection.scala create mode 100644 src/test/scala/test/dialects/identifier quoting.scala create mode 100644 src/test/scala/test/dialects/limit and offset.scala create mode 100644 src/test/scala/test/dialects/string literals.scala diff --git a/src/main/scala/tyql/dialects/boolean literals.scala b/src/main/scala/tyql/dialects/boolean literals.scala new file mode 100644 index 0000000..06cbd87 --- /dev/null +++ b/src/main/scala/tyql/dialects/boolean literals.scala @@ -0,0 +1,6 @@ +package tyql + +object BooleanLiterals: + trait UseTrueFalse extends Dialect: + override def quoteBooleanLiteral(in: Boolean): String = + if in then "TRUE" else "FALSE" diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala new file mode 100644 index 0000000..9501df1 --- /dev/null +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -0,0 +1,72 @@ +package tyql + +trait Dialect: + def name(): String + + protected val reservedKeywords: Set[String] + def quoteIdentifier(id: String): String + + def limitAndOffset(limit: Long, offset: Long): String + + def quoteStringLiteral(in: String, insideLikePattern: Boolean): String + def quoteBooleanLiteral(in: Boolean): String + +object Dialect: + val literal_percent = '\uE000' + val literal_underscore = '\uE001' + + given Dialect = new Dialect + with QuotingIdentifiers.AnsiBehavior + with LimitAndOffset.Separate + with StringLiteral.AnsiSingleQuote + with BooleanLiterals.UseTrueFalse: + def name() = "ANSI SQL Dialect" + + object ansi: + given Dialect = Dialect.given_Dialect + + object postgresql: + given Dialect = new Dialect + with QuotingIdentifiers.PostgresqlBehavior + with LimitAndOffset.Separate + with StringLiteral.PostgresqlBehavior + with BooleanLiterals.UseTrueFalse: + def name() = "PostgreSQL Dialect" + + object mysql: + given Dialect = new MySQLDialect + class MySQLDialect extends Dialect + with QuotingIdentifiers.MysqlBehavior + with LimitAndOffset.MysqlLike + with StringLiteral.MysqlBehavior + with BooleanLiterals.UseTrueFalse: + def name() = "MySQL Dialect" + + object mariadb: + // XXX MariaDB extends MySQL! + given Dialect = new mysql.MySQLDialect with QuotingIdentifiers.MariadbBehavior: + override def name() = "MariaDB Dialect" + + object sqlite: + given Dialect = new Dialect + with QuotingIdentifiers.SqliteBehavior + with LimitAndOffset.Separate + with StringLiteral.AnsiSingleQuote + with BooleanLiterals.UseTrueFalse: + def name() = "SQLite Dialect" + + object h2: + given Dialect = new Dialect + with QuotingIdentifiers.H2Behavior + with LimitAndOffset.Separate + with StringLiteral.AnsiSingleQuote + with BooleanLiterals.UseTrueFalse: + def name() = "H2 Dialect" + + object duckdb: + given Dialect = new Dialect + with QuotingIdentifiers.DuckdbBehavior + with LimitAndOffset.Separate + with StringLiteral.DuckdbBehavior + with BooleanLiterals.UseTrueFalse: + override def name(): String = "DuckDB Dialect" diff --git a/src/main/scala/tyql/dialects/limit and offset.scala b/src/main/scala/tyql/dialects/limit and offset.scala new file mode 100644 index 0000000..9d3d8af --- /dev/null +++ b/src/main/scala/tyql/dialects/limit and offset.scala @@ -0,0 +1,15 @@ +package tyql + +object LimitAndOffset: + // TODO currently unused + trait Separate extends Dialect: + override def limitAndOffset(limit: Long, offset: Long): String = + assert(limit >= 0) + assert(offset >= 0) + s"LIMIT $limit OFFSET $offset" + + trait MysqlLike extends Dialect: + override def limitAndOffset(limit: Long, offset: Long): String = + assert(limit >= 0) + assert(offset >= 0) + s"LIMIT $offset,$limit" diff --git a/src/main/scala/tyql/dialects/quoting identifiers.scala b/src/main/scala/tyql/dialects/quoting identifiers.scala new file mode 100644 index 0000000..a7ad3a8 --- /dev/null +++ b/src/main/scala/tyql/dialects/quoting identifiers.scala @@ -0,0 +1,266 @@ +package tyql + +object QuotingIdentifiers: + // TODO what about `group`, `order`? Do we need to split it into alias context and column name context? + private val keywordsNotToBeQuotedRegardlessOfStandard: Set[String] = Set( + "SUM", "COUNT", "MIN", "MAX", "AVG", "VALUE", "KEY", "TYPE", "DATE", "TIME", "TIMESTAMP", "OF" + ) + + private def needsQuoting(reservedKeywords: Set[String], id: String): Boolean = + // TODO think again more carefully when is `*` allowed and when it's not + ( + (reservedKeywords.contains(id.toUpperCase) + && !keywordsNotToBeQuotedRegardlessOfStandard.contains(id.toUpperCase)) + || + (!id.matches("[a-zA-Z_][a-zA-Z0-9_]*") + && id != "*") + ) + + trait DoubleQuotes extends Dialect: + def quoteIdentifier(id: String): String = + if needsQuoting(reservedKeywords, id) then s""""${id.replace("\"", "\"\"")}"""" else id + + trait Backticks extends Dialect: + def quoteIdentifier(id: String): String = + if needsQuoting(reservedKeywords, id) then s"""`${id.replace("`", "``")}`""" else id + + + trait AnsiBehavior extends DoubleQuotes: + // keywords list is also maintained by PostgreSQL at // https://www.postgresql.org/docs/current/sql-keywords-appendix.html + // last updated 2024-11-15 + override protected val reservedKeywords: Set[String] = Set( + "ABSOLUTE", "ACTION", "ADD", "ALL", "ALLOCATE", "ALTER", "AND", "ANY", "ARE", "AS", + "ASC", "ASSERTION", "AT", "AUTHORIZATION", "AVG", + "BEGIN", "BETWEEN", "BIT", "BY", + "CASCADE", "CASCADED", "CASE", "CAST", "CATALOG", "CHAR", "CHARACTER", "CHECK", + "CLOSE", "COALESCE", "COLLATE", "COLLATION", "COLUMN", "COMMIT", "CONNECT", + "CONNECTION", "CONSTRAINT", "CONSTRAINTS", "CONTINUE", "CONVERT", "CORRESPONDING", + "COUNT", "CREATE", "CROSS", "CURRENT", "CURRENT_DATE", "CURRENT_TIME", + "CURRENT_TIMESTAMP", "CURRENT_USER", "CURSOR", + "DATE", "DAY", "DEALLOCATE", "DEC", "DECIMAL", "DECLARE", "DEFAULT", "DEFERRABLE", + "DEFERRED", "DELETE", "DESC", "DESCRIBE", "DESCRIPTOR", "DIAGNOSTICS", "DISCONNECT", + "DISTINCT", "DOMAIN", "DOUBLE", "DROP", + "ESCAPE", "EXCEPT", "EXCEPTION", + "EXEC", "EXECUTE", "EXISTS", "EXTERNAL", "EXTRACT", + "FALSE", "FETCH", "FIRST", "FLOAT", "FOR", "FOREIGN", "FOUND", "FROM", "FULL", + "GET", "GLOBAL", "GO", "GOTO", "GRANT", "GROUP", + "HAVING", "HOUR", + "IDENTITY", "IMMEDIATE", "IN", "INDICATOR", "INITIALLY", "INNER", "INPUT", + "INSENSITIVE", "INSERT", "INT", "INTEGER", "INTERSECT", "INTERVAL", "INTO", "IS", + "ISOLATION", + "JOIN", + "KEY", + "LANGUAGE", "LAST", "LEADING", "LEFT", "LEVEL", "LIKE", "LOCAL", "LOWER", + "MATCH", "MAX", "MIN", "MINUTE", "MODULE", "MONTH", + "NAMES", "NATIONAL", "NATURAL", "NCHAR", "NEXT", "NO", "NOT", "NULL", "NUMERIC", + "OCTET_LENGTH", "OF", "ON", "ONLY", "OPEN", "OPTION", "OR", "ORDER", "OUTER", + "OUTPUT", "OVERLAPS", + "PAD", "PARTIAL", "POSITION", "PRECISION", "PREPARE", "PRESERVE", "PRIMARY", "PRIOR", + "PRIVILEGES", "PROCEDURE", "PUBLIC", + "READ", "REAL", "REFERENCES", "RELATIVE", "RESTRICT", "REVOKE", "RIGHT", "ROLLBACK", + "ROWS", + "SCHEMA", "SCROLL", "SECOND", "SECTION", "SELECT", "SESSION", "SESSION_USER", "SET", + "SIZE", "SMALLINT", "SOME", "SPACE", "SQL", "SQLCODE", "SQLERROR", "SQLSTATE", + "SUBSTRING", "SUM", "SYSTEM_USER", + "TABLE", "TEMPORARY", "THEN", "TIME", "TIMESTAMP", "TIMEZONE_HOUR", "TIMEZONE_MINUTE", + "TO", "TRAILING", "TRANSACTION", "TRANSLATE", "TRANSLATION", "TRIM", "TRUE", + "UNION", "UNIQUE", "UNKNOWN", "UPDATE", "UPPER", "USAGE", "USER", "USING", + "VALUE", "VALUES", "VARCHAR", "VARYING", "VIEW", + "WHEN", "WHENEVER", "WHERE", "WITH", "WORK", "WRITE", + "YEAR", + "ZONE" + ) + + trait PostgresqlBehavior extends DoubleQuotes: + // https://www.postgresql.org/docs/current/sql-keywords-appendix.html + // last updated 2024-11-15 + override protected val reservedKeywords: Set[String] = Set( + "ANALYSE", "ANALYZE", // Note: both ANALYSE and ANALYZE are reserved in PostgreSQL + "ALL", "AND", "ANY", "AS", "ASC", "ASYMMETRIC", "BOTH", "CASE", "CAST", "CHECK", + "COLLATE", "COLUMN", "CONSTRAINT", "CREATE", "DESC", "DISTINCT", "DO", "ELSE", "END", + "EXCEPT", "FALSE", "FOR", "FROM", "GRANT", "GROUP", "HAVING", "IN", "INITIALLY", "INTERSECT", + "INTO", "LATERAL", "LEADING", "LIMIT", "NOT", "NULL", "OFFSET", "ON", "ONLY", "OR", "ORDER", + "PLACING", "PRIMARY", "REFERENCES", "RETURNING", "SELECT", "SESSION_USER", "SOME", "SYMMETRIC", + "TABLE", "THEN", "TO", "TRAILING", "TRUE", "UNION", "UNIQUE", "USER", "USING", "VARIADIC", + "WHEN", "WHERE", "WINDOW", "WITH" + ) + + + trait MysqlBehavior extends Backticks: + // https://dev.mysql.com/doc/refman/8.0/en/keywords.html + // last updated 2024-11-15 + override protected val reservedKeywords: Set[String] = Set( + "ACCESSIBLE", "ADD", "ALL", "ALTER", "ANALYZE", "AND", "AS", "ASC", "ASENSITIVE", + "BEFORE", "BETWEEN", "BIGINT", "BINARY", "BLOB", "BOTH", "BY", + "CALL", "CASCADE", "CASE", "CHANGE", "CHAR", "CHARACTER", "CHECK", "COLLATE", "COLUMN", + "CONDITION", "CONSTRAINT", "CONTINUE", "CONVERT", "CREATE", "CROSS", "CUBE", "CUME_DIST", + "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", "CURRENT_USER", "CURSOR", + "DATABASE", "DATABASES", "DAY_HOUR", "DAY_MICROSECOND", "DAY_MINUTE", "DAY_SECOND", + "DEC", "DECIMAL", "DECLARE", "DEFAULT", "DELAYED", "DELETE", "DENSE_RANK", "DESC", + "DESCRIBE", "DETERMINISTIC", "DISTINCT", "DISTINCTROW", "DIV", "DOUBLE", "DROP", "DUAL", + "EACH", "ELSE", "ELSEIF", "EMPTY", "ENCLOSED", "ESCAPED", "EXISTS", "EXIT", + "EXPLAIN", "FALSE", "FETCH", "FIRST_VALUE", "FLOAT", "FLOAT4", "FLOAT8", "FOR", + "FORCE", "FOREIGN", "FROM", "FULLTEXT", "FUNCTION", "GENERATED", "GET", + "GRANT", "GROUP", "GROUPING", "GROUPS", "HAVING", "HIGH_PRIORITY", + "HOUR_MICROSECOND", "HOUR_MINUTE", "HOUR_SECOND", + "IF", "IGNORE", "IN", "INDEX", "INFILE", "INNER", "INOUT", "INSENSITIVE", "INSERT", + "INT", "INT1", "INT2", "INT3", "INT4", "INT8", "INTEGER", "INTERVAL", "INTO", "IO_AFTER_GTIDS", + "IO_BEFORE_GTIDS", "IS", "ITERATE", + "JOIN", "JSON_TABLE", "KEY", "KEYS", "KILL", + "LAG", "LAST_VALUE", "LATERAL", "LEAD", "LEADING", "LEAVE", "LEFT", "LIKE", "LIMIT", + "LINEAR", "LINES", "LOAD", "LOCALTIME", "LOCALTIMESTAMP", "LOCK", "LONG", "LONGBLOB", + "LONGTEXT", "LOOP", "LOW_PRIORITY", + "MASTER_BIND", "MASTER_SSL_VERIFY_SERVER_CERT", "MATCH", "MAXVALUE", "MEDIUMBLOB", + "MEDIUMINT", "MEDIUMTEXT", "MIDDLEINT", "MINUTE_MICROSECOND", "MINUTE_SECOND", "MOD", + "MODIFIES", + "NATURAL", "NOT", "NO_WRITE_TO_BINLOG", "NTH_VALUE", "NTILE", "NULL", "NUMERIC", + "OF", "ON", "OPTIMIZE", "OPTIMIZER_COSTS", "OPTION", "OPTIONALLY", "OR", "ORDER", "OUT", + "OUTER", "OUTFILE", "OVER", + "PARTITION", "PERCENT_RANK", "PRECISION", "PRIMARY", "PROCEDURE", "PURGE", + "RANGE", "RANK", "READ", "READS", "READ_WRITE", "REAL", "RECURSIVE", "REFERENCES", "REGEXP", + "RELEASE", "RENAME", "REPEAT", "REPLACE", "REQUIRE", "RESIGNAL", "RESTRICT", "RETURN", + "REVOKE", "RIGHT", "RLIKE", "ROW", "ROWS", + "SCHEMA", "SCHEMAS", "SECOND_MICROSECOND", "SELECT", "SENSITIVE", "SEPARATOR", "SET", + "SHOW", "SIGNAL", "SMALLINT", "SPATIAL", "SPECIFIC", "SQL", "SQLEXCEPTION", "SQLSTATE", + "SQLWARNING", "SQL_BIG_RESULT", "SQL_CALC_FOUND_ROWS", "SQL_SMALL_RESULT", "SSL", + "STARTING", "STORED", "STRAIGHT_JOIN", "SYSTEM", + "TABLE", "TERMINATED", "THEN", "TINYBLOB", "TINYINT", "TINYTEXT", "TO", "TRAILING", + "TRIGGER", "TRUE", + "UNDO", "UNION", "UNIQUE", "UNLOCK", "UNSIGNED", "UPDATE", "USAGE", "USE", "USING", + "UTC_DATE", "UTC_TIME", "UTC_TIMESTAMP", + "VALUES", "VARBINARY", "VARCHAR", "VARCHARACTER", "VARYING", "VIRTUAL", + "WHEN", "WHERE", "WHILE", "WINDOW", "WITH", "WRITE", + "XOR", "YEAR_MONTH", "ZEROFILL" + ) + + trait MariadbBehavior extends Backticks: + // https://mariadb.com/kb/en/reserved-words/ + // last updated 2024-11-15 + override protected val reservedKeywords: Set[String] = Set( + "ACCESSIBLE", "ADD", "ALL", "ALTER", "ANALYZE", "AND", "AS", "ASC", "ASENSITIVE", + "BEFORE", "BETWEEN", "BIGINT", "BINARY", "BLOB", "BOTH", "BY", + "CALL", "CASCADE", "CASE", "CHANGE", "CHAR", "CHARACTER", "CHECK", "COLLATE", + "COLUMN", "CONDITION", "CONSTRAINT", "CONTINUE", "CONVERT", "CREATE", "CROSS", + "CURRENT_DATE", "CURRENT_ROLE", "CURRENT_TIME", "CURRENT_TIMESTAMP", "CURRENT_USER", "CURSOR", + "DATABASE", "DATABASES", "DAY_HOUR", "DAY_MICROSECOND", "DAY_MINUTE", "DAY_SECOND", + "DEC", "DECIMAL", "DECLARE", "DEFAULT", "DELAYED", "DELETE", "DELETE_DOMAIN_ID", "DESC", + "DESCRIBE", "DETERMINISTIC", "DISTINCT", "DISTINCTROW", "DIV", "DO_DOMAIN_IDS", "DOUBLE", "DROP", "DUAL", + "EACH", "ELSE", "ELSEIF", "ENCLOSED", "ESCAPED", "EXCEPT", "EXISTS", "EXIT", "EXPLAIN", + "FALSE", "FETCH", "FLOAT", "FLOAT4", "FLOAT8", "FOR", "FORCE", "FOREIGN", "FROM", "FULLTEXT", + "GENERAL", "GRANT", "GROUP", + "HAVING", "HIGH_PRIORITY", "HOUR_MICROSECOND", "HOUR_MINUTE", "HOUR_SECOND", + "IF", "IGNORE", "IGNORE_DOMAIN_IDS", "IGNORE_SERVER_IDS", "IN", "INDEX", "INFILE", "INNER", + "INOUT", "INSENSITIVE", "INSERT", "INT", "INT1", "INT2", "INT3", "INT4", "INT8", + "INTEGER", "INTERSECT", "INTERVAL", "INTO", "IS", "ITERATE", + "JOIN", + "KEY", "KEYS", "KILL", + "LEADING", "LEAVE", "LEFT", "LIKE", "LIMIT", "LINEAR", "LINES", "LOAD", "LOCALTIME", + "LOCALTIMESTAMP", "LOCK", "LONG", "LONGBLOB", "LONGTEXT", "LOOP", "LOW_PRIORITY", + "MASTER_HEARTBEAT_PERIOD", "MASTER_SSL_VERIFY_SERVER_CERT", "MATCH", "MAXVALUE", + "MEDIUMBLOB", "MEDIUMINT", "MEDIUMTEXT", "MIDDLEINT", "MINUTE_MICROSECOND", + "MINUTE_SECOND", "MOD", "MODIFIES", + "NATURAL", "NOT", "NO_WRITE_TO_BINLOG", "NULL", "NUMERIC", + "OFFSET", "ON", "OPTIMIZE", "OPTION", "OPTIONALLY", "OR", "ORDER", "OUT", "OUTER", + "OUTFILE", "OVER", + "PAGE_CHECKSUM", "PARSE_VCOL_EXPR", "PARTITION", "PRECISION", "PRIMARY", "PROCEDURE", "PURGE", + "RANGE", "READ", "READS", "READ_WRITE", "REAL", "RECURSIVE", "REF_SYSTEM_ID", + "REFERENCES", "REGEXP", "RELEASE", "RENAME", "REPEAT", "REPLACE", "REQUIRE", + "RESIGNAL", "RESTRICT", "RETURN", "RETURNING", "REVOKE", "RIGHT", "RLIKE", "ROW_NUMBER", "ROWS", + "SCHEMA", "SCHEMAS", "SECOND_MICROSECOND", "SELECT", "SENSITIVE", "SEPARATOR", "SET", + "SHOW", "SIGNAL", "SLOW", "SMALLINT", "SPATIAL", "SPECIFIC", "SQL", "SQLEXCEPTION", + "SQLSTATE", "SQLWARNING", "SQL_BIG_RESULT", "SQL_CALC_FOUND_ROWS", "SQL_SMALL_RESULT", + "SSL", "STARTING", "STATS_AUTO_RECALC", "STATS_PERSISTENT", "STATS_SAMPLE_PAGES", + "STRAIGHT_JOIN", + "TABLE", "TERMINATED", "THEN", "TINYBLOB", "TINYINT", "TINYTEXT", "TO", "TRAILING", + "TRIGGER", "TRUE", + "UNDO", "UNION", "UNIQUE", "UNLOCK", "UNSIGNED", "UPDATE", "USAGE", "USE", "USING", + "UTC_DATE", "UTC_TIME", "UTC_TIMESTAMP", + "VALUES", "VARBINARY", "VARCHAR", "VARCHARACTER", "VARYING", + "WHEN", "WHERE", "WHILE", "WINDOW", "WITH", "WRITE", + "XOR", + "YEAR_MONTH", + "ZEROFILL" + ) + + trait SqliteBehavior extends DoubleQuotes: + // https://www.sqlite.org/lang_keywords.html + // last updated 2024-11-15 + override protected val reservedKeywords: Set[String] = Set( + "ABORT", "ACTION", "ADD", "AFTER", "ALL", "ALTER", "ALWAYS", "ANALYZE", "AND", "AS", + "ASC", "ATTACH", "AUTOINCREMENT", "BEFORE", "BEGIN", "BETWEEN", "BY", "CASCADE", + "CASE", "CAST", "CHECK", "COLLATE", "COLUMN", "COMMIT", "CONFLICT", "CONSTRAINT", + "CREATE", "CROSS", "CURRENT", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", + "DATABASE", "DEFAULT", "DEFERRABLE", "DEFERRED", "DELETE", "DESC", "DETACH", + "DISTINCT", "DO", "DROP", "EACH", "ELSE", "END", "ESCAPE", "EXCEPT", "EXCLUDE", + "EXCLUSIVE", "EXISTS", "EXPLAIN", "FAIL", "FILTER", "FIRST", "FOLLOWING", "FOR", + "FOREIGN", "FROM", "FULL", "GENERATED", "GLOB", "GROUP", "GROUPS", "HAVING", "IF", + "IGNORE", "IMMEDIATE", "IN", "INDEX", "INDEXED", "INITIALLY", "INNER", "INSERT", + "INSTEAD", "INTERSECT", "INTO", "IS", "ISNULL", "JOIN", "KEY", "LAST", "LEFT", + "LIKE", "LIMIT", "MATCH", "MATERIALIZED", "NATURAL", "NO", "NOT", "NOTHING", + "NOTNULL", "NULL", "NULLS", "OF", "OFFSET", "ON", "OR", "ORDER", "OTHERS", "OUTER", + "OVER", "PARTITION", "PLAN", "PRAGMA", "PRECEDING", "PRIMARY", "QUERY", "RAISE", + "RANGE", "RECURSIVE", "REFERENCES", "REGEXP", "REINDEX", "RELEASE", "RENAME", + "REPLACE", "RESTRICT", "RETURNING", "RIGHT", "ROLLBACK", "ROW", "ROWS", "SAVEPOINT", + "SELECT", "SET", "TABLE", "TEMP", "TEMPORARY", "THEN", "TIES", "TO", "TRANSACTION", + "TRIGGER", "UNBOUNDED", "UNION", "UNIQUE", "UPDATE", "USING", "VACUUM", "VALUES", + "VIEW", "VIRTUAL", "WHEN", "WHERE", "WINDOW", "WITH", "WITHOUT" + ) + + trait H2Behavior extends DoubleQuotes: + // https://h2database.com/html/grammar.html + // last updated 2024-11-15 + override protected val reservedKeywords: Set[String] = Set( + // Basic SQL keywords + "ALL", "AND", "ANY", "ARRAY", "AS", "ASC", "BETWEEN", "BOTH", "CASE", "CAST", + "CHECK", "CONSTRAINT", "CROSS", "CURRENT", "CURRENT_DATE", "CURRENT_TIME", + "CURRENT_TIMESTAMP", "CURRENT_USER", "DISTINCT", "EXCEPT", "EXISTS", "FALSE", + "FETCH", "FOR", "FOREIGN", "FROM", "FULL", "GROUP", "HAVING", "IF", "ILIKE", + "IN", "INNER", "INTERSECT", "INTERVAL", "IS", "JOIN", "LEADING", "LEFT", "LIKE", + "LIMIT", "LOCALTIME", "LOCALTIMESTAMP", "MINUS", "NATURAL", "NOT", "NULL", + "OFFSET", "ON", "OR", "ORDER", "PRIMARY", "QUALIFY", "REGEXP", "RIGHT", "ROW", + "SELECT", "SYSDATE", "SYSTIME", "SYSTIMESTAMP", "TABLE", "TODAY", "TOP", "TRAILING", + "TRUE", "UNION", "UNIQUE", "UNKNOWN", "USING", "VALUES", "WHERE", "WINDOW", "WITH", + + // Data manipulation + "DELETE", "INSERT", "MERGE", "REPLACE", "UPDATE", "UPSERT", + + // Data definition + "ADD", "ALTER", "COLUMN", "CREATE", "DATABASE", "DROP", "INDEX", "SCHEMA", "SET", + "TABLE", "VIEW", + + // Transaction control + "COMMIT", "ROLLBACK", "SAVEPOINT", "START", + + // H2-specific + "_ROWID_", "AUTOCOMMIT", "CACHED", "CHECKPOINT", "EXCLUSIVE", "IGNORECASE", + "IFEXISTS", "IFNOTEXISTS", "MEMORY", "MINUS", "NEXT", "OF", "OFF", "PASSWORD", + "READONLY", "REFERENTIAL_INTEGRITY", "REUSE", "ROWNUM", "SEQUENCE", "TEMP", + "TEMPORARY", "TRIGGER", "VALUE", "YEAR", + + // Data types + "BINARY", "BLOB", "BOOLEAN", "CHAR", "CHARACTER", "CLOB", "DATE", "DECIMAL", + "DOUBLE", "FLOAT", "INT", "INTEGER", "LONG", "NUMBER", "NUMERIC", "REAL", + "SMALLINT", "TIME", "TIMESTAMP", "TINYINT", "VARCHAR" + ) + + trait DuckdbBehavior extends DoubleQuotes: + // SELECT keyword_name FROM duckdb_keywords() WHERE keyword_category IN ('reserved', 'type_function') + // last updated 2024-11-15 + override protected val reservedKeywords: Set[String] = Set( + "ALL", "ANALYSE", "ANALYZE", "AND", "ANY", "ARRAY", "AS", "ASC", + "ASYMMETRIC", "BOTH", "CASE", "CAST", "CHECK", "COLLATE", "COLUMN", + "CONSTRAINT", "CREATE", "DEFAULT", "DEFERRABLE", "DESC", "DISTINCT", + "DO", "ELSE", "END", "EXCEPT", "FALSE", "FETCH", "FOR", "FOREIGN", + "FROM", "GRANT", "GROUP", "HAVING", "IN", "INITIALLY", "INTERSECT", + "INTO", "LATERAL", "LEADING", "LIMIT", "NOT", "NULL", "OFFSET", + "ON", "ONLY", "OR", "ORDER", "PIVOT", "PIVOT_LONGER", "PIVOT_WIDER", + "PLACING", "PRIMARY", "REFERENCES", "RETURNING", "SELECT", "SHOW", + "SOME", "SYMMETRIC", "TABLE", "THEN", "TO", "TRAILING", "TRUE", + "UNION", "UNIQUE", "UNPIVOT", "USING", "VARIADIC", "WHEN", "WHERE", + "WINDOW", "WITH" + ) ++ Set( + "ANTI", "ASOF", "AUTHORIZATION", "BINARY", "CROSS", "FULL", "ILIKE", + "INNER", "IS", "ISNULL", "JOIN", "LEFT", "LIKE", "MAP", "NATURAL", + "NOTNULL", "OUTER", "OVERLAPS", "POSITIONAL", "RIGHT", "SEMI", + "SIMILAR", "STRUCT", "TABLESAMPLE", "TRY_CAST", "VERBOSE" + ) diff --git a/src/main/scala/tyql/dialects/string literals.scala b/src/main/scala/tyql/dialects/string literals.scala new file mode 100644 index 0000000..9f22193 --- /dev/null +++ b/src/main/scala/tyql/dialects/string literals.scala @@ -0,0 +1,77 @@ +package tyql + +object StringLiteral: + // XXX for now it's impossible to input things like \u4F60. Unclear if worth implementing since we're in Scala. + // XXX unclear how H2 behaves. It appears to follow the ANSI standard? + // XXX TODO make sure this plays nicely with LIKE. Maybe we will allow .like_pattern and .like_literal? + + private def handleLiteralPatterns(insideLikePattern: Boolean, in: String): (String, Boolean) = + // ESCAPE is needed for ANSI, H2, DuckDB, not needed in PostgreSQL, SQLite + // still, it's safer to emit since all these backend have configuration options + // MySQL, MariaDB REJECT the ESCAPE keyword in LIKE patterns + if insideLikePattern && (in.contains(Dialect.literal_percent) || in.contains(Dialect.literal_underscore)) then + (in.replace(Dialect.literal_percent.toString, "\\%").replace(Dialect.literal_underscore.toString, "\\_"), true) + else + (in.replace(Dialect.literal_percent, '%').replace(Dialect.literal_underscore, '_'), false) + + trait AnsiSingleQuote extends Dialect: + // last updated 2024-11-15 + // https://www.sqlite.org/lang_expr.html + // https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-STRINGS + override def quoteStringLiteral(lit: String, insideLikePattern: Boolean): String = + val (in, shouldAddEscape) = handleLiteralPatterns(insideLikePattern, lit) + val out = "'" + in.replace("'", "''") + "'" + if shouldAddEscape then s"$out ESCAPE '\\'" else out + + trait PostgresqlBehavior extends Dialect: + // last updated 2024-11-15 + // https://www.postgresql.org/docs/current/sql-syntax-lexical.html + override def quoteStringLiteral(lit: String, insideLikePattern: Boolean): String = + val (in, shouldAddEscape) = handleLiteralPatterns(insideLikePattern, lit) + val out = if in.exists("\n\r\t\b\f\\".contains) then + "E'" + in.replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\b", "\\b") + .replace("\f", "\\f") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + "'" + else + "'" + in.replace("'", "''") + "'" + if shouldAddEscape then s"$out ESCAPE '\\'" else out + + trait MysqlBehavior extends Dialect: + // last updated 2024-11-15 + // https://dev.mysql.com/doc/refman/8.4/en/string-literals.html + // https://mariadb.com/kb/en/string-literals/ + // XXX _ and % have different meaning in LIKE strings. For now we never escape them, + // but this means that you cannot encode a literal % in the pattern. + override def quoteStringLiteral(lit: String, insideLikePattern: Boolean): String = + val (in, shouldAddEscape) = handleLiteralPatterns(insideLikePattern, lit) + val out = "'" + in.replace("\\", "\\\\") + .replace("\u0000", "\\0") + .replace("'", "\\'") + .replace("\"", "\\\"") + .replace("\b", "\\b") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + .replace("\u001A", "\\Z") + "'" + out // ignore `ESCAPE` since MySQL/MariaDB do not support it + + trait DuckdbBehavior extends Dialect: + // last updated 2024-11-15 + // https://duckdb.org/docs/sql/data_types/literal_types.html#string-literals + override def quoteStringLiteral(lit: String, insideLikePattern: Boolean): String = + val (in, shouldAddEscape) = handleLiteralPatterns(insideLikePattern, lit) + val out = if in.exists("\n\r\t\b\f\\".contains) then + "E'" + in.replace("\\", "\\\\") + .replace("'", "''") // different from PostgreSQL + .replace("\b", "\\b") + .replace("\f", "\\f") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + "'" + else + "'" + in.replace("'", "''") + "'" + if shouldAddEscape then s"$out ESCAPE '\\'" else out diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index f9acc16..72d62f1 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -187,6 +187,7 @@ object Expr: case class BooleanLit($value: Boolean) extends Expr[Boolean, NonScalarExpr] // given Conversion[Boolean, BooleanLit] = BooleanLit(_) + // TODO why does this break things? /** Should be able to rely on the implicit conversions, but not always. * One approach is to overload, another is to provide a user-facing toExpr diff --git a/src/main/scala/tyql/ir/QueryIRNode.scala b/src/main/scala/tyql/ir/QueryIRNode.scala index 6eefd6d..08952bc 100644 --- a/src/main/scala/tyql/ir/QueryIRNode.scala +++ b/src/main/scala/tyql/ir/QueryIRNode.scala @@ -44,25 +44,25 @@ case class ProjectClause(children: Seq[QueryIRNode], ast: Expr[?, ?]) extends Qu * TODO: generate anonymous names, or allow generated queries to be unnamed, or only allow named tuple results? * Note projected attributes with names is not the same as aliasing, and just exists for readability */ -case class AttrExpr(child: QueryIRNode, projectedName: Option[String], ast: Expr[?, ?]) extends QueryIRNode: +case class AttrExpr(child: QueryIRNode, projectedName: Option[String], ast: Expr[?, ?])(using d: Dialect) extends QueryIRNode: val asStr = projectedName match - case Some(value) => s" as $value" + case Some(value) => s" as ${d.quoteIdentifier(value)}" case None => "" override def toSQLString(): String = s"${child.toSQLString()}$asStr" /** * Attribute access expression, e.g. `table.rowName`. */ -case class SelectExpr(attrName: String, from: QueryIRNode, ast: Expr[?, ?]) extends QueryIRLeaf: - override def toSQLString(): String = s"${from.toSQLString()}.$attrName" +case class SelectExpr(attrName: String, from: QueryIRNode, ast: Expr[?, ?])(using d: Dialect) extends QueryIRLeaf: + override def toSQLString(): String = s"${from.toSQLString()}.${d.quoteIdentifier(attrName)}" /** * A variable that points to a table or subquery. */ -case class QueryIRVar(toSub: RelationOp, name: String, ast: Expr.Ref[?, ?]) extends QueryIRLeaf: - override def toSQLString() = toSub.alias +case class QueryIRVar(toSub: RelationOp, name: String, ast: Expr.Ref[?, ?])(using d: Dialect) extends QueryIRLeaf: + override def toSQLString() = d.quoteIdentifier(toSub.alias) - override def toString: String = s"VAR(${toSub.alias}.$name)" + override def toString: String = s"VAR(${toSub.alias}.${d.quoteIdentifier(name)})" /** * Literals. diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index 76def1c..55e5533 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -330,7 +330,7 @@ object QueryIRTree: ) ProjectClause(children, p) - private def generateExpr(ast: Expr[?, ?], symbols: SymbolTable): QueryIRNode = + private def generateExpr(ast: Expr[?, ?], symbols: SymbolTable)(using d: Dialect): QueryIRNode = ast match case ref: Expr.Ref[?, ?] => val name = ref.stringRef() @@ -367,8 +367,8 @@ object QueryIRTree: ) case l: Expr.DoubleLit => Literal(s"${l.$value}", l) case l: Expr.IntLit => Literal(s"${l.$value}", l) - case l: Expr.StringLit => Literal(s"\"${l.$value}\"", l) - case l: Expr.BooleanLit => Literal(s"\"${l.$value}\"", l) + case l: Expr.StringLit => Literal(d.quoteStringLiteral(l.$value, insideLikePattern=false), l) // TODO fix this for LIKE patterns + case l: Expr.BooleanLit => Literal(d.quoteBooleanLiteral(l.$value), l) case l: Expr.Lower[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"LOWER($o)", l) case a: AggregationExpr[?] => generateAggregation(a, symbols) case a: Aggregation[?, ?] => generateQuery(a, symbols).appendFlag(SelectFlags.ExprLevel) diff --git a/src/test/scala/test/dialects/boolean literals.scala b/src/test/scala/test/dialects/boolean literals.scala new file mode 100644 index 0000000..6718b8e --- /dev/null +++ b/src/test/scala/test/dialects/boolean literals.scala @@ -0,0 +1,24 @@ +package test.integration.booleanliterals + +import test.TestDatabase +import test.SQLStringQueryTest +import test.query.{AllCommerceDBs, ShippingInfo, commerceDBs, Buyer} +import tyql.* +import language.experimental.namedTuples +import NamedTuple.* +import scala.language.implicitConversions + +class BooleanTrueRendering extends SQLStringQueryTest[AllCommerceDBs, String] { + override def testDescription: String = "Just LIMIT remains uniform across all dialects" + def query() = testDB.tables.products.filter{ _ => tyql.Expr.BooleanLit(true) }.map( c => c.name) + def expectedQueryPattern = """SELECT product$A.name FROM product as product$A WHERE TRUE""" +} + +class BooleanFalseRendering extends SQLStringQueryTest[AllCommerceDBs, String] { + override def testDescription: String = "Just LIMIT remains uniform across all dialects" + def query() = testDB.tables.products.filter{ _ => tyql.Expr.BooleanLit(false) }.map( c => c.name) + def expectedQueryPattern = """SELECT product$A.name FROM product as product$A WHERE FALSE""" +} + +// TODO is there are simpler way of doing `select true`? +// TODO why do I have to do tyql.Expr.BooleanLit(false) ? diff --git a/src/test/scala/test/dialects/dialect selection.scala b/src/test/scala/test/dialects/dialect selection.scala new file mode 100644 index 0000000..5c84d87 --- /dev/null +++ b/src/test/scala/test/dialects/dialect selection.scala @@ -0,0 +1,44 @@ +package test.integration.dialectselection + +import munit.FunSuite +import tyql.Dialect + +class DialectSelection extends FunSuite { + private def behavior()(using d: Dialect): String = d.name() + + test("ANSI selected by default") { + assertEquals(behavior(), "ANSI SQL Dialect") + } + + test("ANSI selected also by import") { + import Dialect.ansi.given + assertEquals(behavior(), "ANSI SQL Dialect") + } + + test("All the other dialects can be selected") { + { + import Dialect.postgresql.given + assertEquals(behavior(), "PostgreSQL Dialect") + } + { + import Dialect.mariadb.given + assertEquals(behavior(), "MariaDB Dialect") + } + { + import Dialect.mysql.given + assertEquals(behavior(), "MySQL Dialect") + } + { + import Dialect.sqlite.given + assertEquals(behavior(), "SQLite Dialect") + } + { + import Dialect.h2.given + assertEquals(behavior(), "H2 Dialect") + } + { + import Dialect.duckdb.given + assertEquals(behavior(), "DuckDB Dialect") + } + } +} diff --git a/src/test/scala/test/dialects/identifier quoting.scala b/src/test/scala/test/dialects/identifier quoting.scala new file mode 100644 index 0000000..fac3f61 --- /dev/null +++ b/src/test/scala/test/dialects/identifier quoting.scala @@ -0,0 +1,66 @@ +package test.integration.identifierquoting + +import munit.FunSuite +import tyql.Dialect + +class IdentifierQuoting extends FunSuite { + private val dialects = Set( + Dialect.ansi.given_Dialect, + Dialect.postgresql.given_Dialect, + Dialect.mysql.given_Dialect, + Dialect.mariadb.given_Dialect, + Dialect.h2.given_Dialect, + Dialect.duckdb.given_Dialect, + Dialect.sqlite.given_Dialect, + ) + + test("common names are not quoted") { + for (d <- dialects ; e <- Set("sum", "min", "max", "avg", "count")) { + assertEquals(d.quoteIdentifier(e), e) + assertEquals(d.quoteIdentifier(e.toUpperCase), e.toUpperCase) + } + } + + test("names with spaces are quoted") { + for (d <- dialects; e <- Set("a b", "c d", "a b d")) { + assertNotEquals(d.quoteIdentifier(e), e) + assertNotEquals(d.quoteIdentifier(e.toUpperCase), e.toUpperCase) + assertEquals(d.quoteIdentifier(e).length, e.length + 2) + assertEquals(d.quoteIdentifier(e.toUpperCase).length, e.toUpperCase.length + 2) + } + } + + test("names starting with numbers") { + val needQuoting = Set("1abc", "12r", "0_") + val doNotNeedQuoting = Set("abc1", "r12", "_0") + for (d <- dialects; e <- needQuoting) { + assertNotEquals(d.quoteIdentifier(e), e) + assertEquals(d.quoteIdentifier(e).length, e.length + 2) + } + for (d <- dialects; e <- doNotNeedQuoting) { + assertEquals(d.quoteIdentifier(e), e) + } + } + + test("reserved keywords are quoted") { + val needQuoting = Set("select", "where", "from") + for (d <- dialects; e <- needQuoting) { + assertNotEquals(d.quoteIdentifier(e), e) + assertEquals(d.quoteIdentifier(e).length, e.length + 2) + } + } + + test("different reserved keywords per database") { + assertEquals(Dialect.postgresql.given_Dialect.quoteIdentifier("user"), "\"user\"") + assertEquals(Dialect.sqlite.given_Dialect.quoteIdentifier("user"), "user") + } + + test("escaping inner quotes") { + assertEquals(Dialect.mariadb.given_Dialect.quoteIdentifier("a``b`c"), "`a````b``c`") + assertEquals(Dialect.mysql.given_Dialect.quoteIdentifier("a``b`c"), "`a````b``c`") + + for (d <- dialects -- Set(Dialect.mariadb.given_Dialect, Dialect.mysql.given_Dialect)) { + assertEquals(d.quoteIdentifier("a\"\"b\"c"), "\"a\"\"\"\"b\"\"c\"") + } + } +} diff --git a/src/test/scala/test/dialects/limit and offset.scala b/src/test/scala/test/dialects/limit and offset.scala new file mode 100644 index 0000000..a09e906 --- /dev/null +++ b/src/test/scala/test/dialects/limit and offset.scala @@ -0,0 +1,72 @@ +package test.integration.limitandoffset + +import munit.FunSuite +import test.TestDatabase +import tyql.Dialect +import test.SQLStringQueryTest +import test.query.{AllCommerceDBs, ShippingInfo, commerceDBs, Buyer} +import tyql.* +import language.experimental.namedTuples +import NamedTuple.* +import scala.language.implicitConversions + +private val mysqlDialects = Set( + Dialect.mysql.given_Dialect, + Dialect.mariadb.given_Dialect, +) +private val otherDialects = Set( + Dialect.ansi.given_Dialect, + Dialect.postgresql.given_Dialect, + Dialect.h2.given_Dialect, + Dialect.duckdb.given_Dialect, + Dialect.sqlite.given_Dialect, +) +private val dialects = mysqlDialects ++ otherDialects + +class JustLimitUnaffected extends SQLStringQueryTest[AllCommerceDBs, String] { + override def testDescription: String = "Just LIMIT remains uniform across all dialects" + def query() = testDB.tables.products.map { c => c.name }.limit(10) + def expectedQueryPattern = """SELECT product$A.name FROM product as product$A LIMIT 10""" +} + +class JustOffsetUnaffected extends SQLStringQueryTest[AllCommerceDBs, String] { + override def testDescription: String = "Just OFFSET remains uniform across all dialects" + def query() = testDB.tables.products.map { c => c.name }.offset(30) + def expectedQueryPattern = """SELECT product$A.name FROM product as product$A OFFSET 30""" +} + +class BothTogetherOnMySQL extends SQLStringQueryTest[AllCommerceDBs, String] { + override def munitIgnore: Boolean = true + override def testDescription: String = "LIMIT+OFFSET is special on MySQL" + def query() = + import Dialect.mysql.given + testDB.tables.products.map { c => c.name }.limit(10).offset(30) + def expectedQueryPattern = """SELECT product$A.name FROM product as product$A LIMIT 30,10""" +} + +class BothTogetherOnMariaDB extends SQLStringQueryTest[AllCommerceDBs, String] { + override def munitIgnore: Boolean = true + override def testDescription: String = "LIMIT+OFFSET is special on MySQL" + def query() = + import Dialect.mysql.given + testDB.tables.products.map { c => c.name }.limit(10).offset(30) + def expectedQueryPattern = """SELECT product$A.name FROM product as product$A LIMIT 30,10""" +} + +class SeparateOnPostgresql extends SQLStringQueryTest[AllCommerceDBs, String] { + override def testDescription: String = "LIMIT+OFFSET is special on MySQL" + def query() = + import Dialect.postgresql.given + testDB.tables.products.map { c => c.name }.limit(10).offset(30) + def expectedQueryPattern = """SELECT product$A.name FROM product as product$A LIMIT 10 OFFSET 30""" +} + +class SeparateOnSqlite extends SQLStringQueryTest[AllCommerceDBs, String] { + override def testDescription: String = "LIMIT+OFFSET is special on MySQL" + def query() = + import Dialect.sqlite.given + testDB.tables.products.map { c => c.name }.limit(10).offset(30) + def expectedQueryPattern = """SELECT product$A.name FROM product as product$A LIMIT 10 OFFSET 30""" +} + +// TODO parametrize these tests better later diff --git a/src/test/scala/test/dialects/string literals.scala b/src/test/scala/test/dialects/string literals.scala new file mode 100644 index 0000000..d9904c7 --- /dev/null +++ b/src/test/scala/test/dialects/string literals.scala @@ -0,0 +1,156 @@ +package test.integration.stringliteralescaping + +import munit.FunSuite +import test.expensiveTest +import tyql.Dialect +import java.sql.{Connection, DriverManager} + +class StringLiteralDBTest extends FunSuite { + def withConnection[A](url: String, user: String = "", password: String = "")(f: Connection => A): A = { + var conn: Connection = null + try { + conn = DriverManager.getConnection(url, user, password) + f(conn) + } finally { + if (conn != null) conn.close() + } + } + + private def testStringLiteral(dialect: Dialect, conn: Connection, input: String) = { + val quoted = dialect.quoteStringLiteral(input, insideLikePattern=false) + val stmt = conn.createStatement() + val rs = stmt.executeQuery(s"SELECT $quoted as str") + assert(rs.next()) + assertEquals(rs.getString("str"), input) + } + + val interestingStrings = List( + "a'b", + "a\"b", + "a\\b", + "a\bb", // Backspace + "a\fb", + "a\nb", + "a\rb", + "a\tb", + "a\u001Ab", // Ctrl+Z + "a%b", // LIKE wildcard % + "a_b" // LIKE wildcard _ + ) + + test("string literals are handled per dialect") { + assertEquals(Dialect.postgresql.given_Dialect.quoteStringLiteral("a\nb", insideLikePattern=false), "E'a\\nb'"); + assertEquals(Dialect.sqlite.given_Dialect.quoteStringLiteral("a\nb", insideLikePattern=false), "'a\nb'"); + } + + private def testLikePatterns(dialect: Dialect, conn: Connection) = { + val stmt = conn.createStatement() + + val underscorePattern = dialect.quoteStringLiteral("a_c", insideLikePattern=true) + val literalUnderscorePattern = dialect.quoteStringLiteral(s"a${Dialect.literal_underscore}c", insideLikePattern=true) + + val rs1 = stmt.executeQuery(s"SELECT 'abc' LIKE $underscorePattern as result") + assert(rs1.next()) + assert(rs1.getBoolean("result")) + val rs2 = stmt.executeQuery(s"SELECT 'a_c' LIKE $underscorePattern as result") + assert(rs2.next()) + assert(rs2.getBoolean("result")) + val rs3 = stmt.executeQuery(s"SELECT 'abc' LIKE $literalUnderscorePattern as result") + assert(rs3.next()) + assert(!rs3.getBoolean("result")) + val rs4 = stmt.executeQuery(s"SELECT 'a_c' LIKE $literalUnderscorePattern as result") + assert(rs4.next()) + assert(rs4.getBoolean("result"), "the pattern was " + s"SELECT 'a_c' LIKE $literalUnderscorePattern as result") + + val percentPattern = dialect.quoteStringLiteral("a%c", insideLikePattern=true) + val literalPercentPattern = dialect.quoteStringLiteral(s"a${Dialect.literal_percent}c", insideLikePattern=true) + + val rs5 = stmt.executeQuery(s"SELECT 'abc' LIKE $percentPattern as result") + assert(rs5.next()) + assert(rs5.getBoolean("result")) + val rs6 = stmt.executeQuery(s"SELECT 'a%c' LIKE $percentPattern as result") + assert(rs6.next()) + assert(rs6.getBoolean("result")) + val rs7 = stmt.executeQuery(s"SELECT 'abc' LIKE $literalPercentPattern as result") + assert(rs7.next()) + assert(!rs7.getBoolean("result")) + val rs8 = stmt.executeQuery(s"SELECT 'a%c' LIKE $literalPercentPattern as result") + assert(rs8.next()) + assert(rs8.getBoolean("result")) + } + + test("PostgreSQL LIKE patterns".tag(expensiveTest)) { + withConnection("jdbc:postgresql://localhost:5433/testdb", "testuser", "testpass") { conn => + testLikePatterns(Dialect.postgresql.given_Dialect, conn) + } + } + + test("MySQL LIKE patterns".tag(expensiveTest)) { + withConnection("jdbc:mysql://localhost:3307/testdb", "testuser", "testpass") { conn => + testLikePatterns(Dialect.mysql.given_Dialect, conn) + } + } + + test("MariaDB LIKE patterns".tag(expensiveTest)) { + withConnection("jdbc:mariadb://localhost:3308/testdb", "testuser", "testpass") { conn => + testLikePatterns(Dialect.mariadb.given_Dialect, conn) + } + } + + test("SQLite LIKE patterns".tag(expensiveTest)) { + withConnection("jdbc:sqlite::memory:") { conn => + testLikePatterns(Dialect.sqlite.given_Dialect, conn) + } + } + + test("H2 LIKE patterns".tag(expensiveTest)) { + withConnection("jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1") { conn => + testLikePatterns(Dialect.h2.given_Dialect, conn) + } + } + + test("DuckDB LIKE patterns".tag(expensiveTest)) { + withConnection("jdbc:duckdb:") { conn => + testLikePatterns(Dialect.duckdb.given_Dialect, conn) + } + } + + test("PostgreSQL string literals".tag(expensiveTest)) { + withConnection("jdbc:postgresql://localhost:5433/testdb", "testuser", "testpass") { conn => + val dialect = Dialect.postgresql.given_Dialect + interestingStrings.foreach(input => testStringLiteral(dialect, conn, input)) + } + } + + test("MySQL and MariaDB string literals".tag(expensiveTest)) { + withConnection("jdbc:mysql://localhost:3307/testdb", "testuser", "testpass") { conn => + val dialect = Dialect.mysql.given_Dialect + interestingStrings.foreach(input => testStringLiteral(dialect, conn, input)) + } + withConnection("jdbc:mariadb://localhost:3308/testdb", "testuser", "testpass") { conn => + val dialect = Dialect.mariadb.given_Dialect + interestingStrings.foreach(input => testStringLiteral(dialect, conn, input)) + } + } + + test("SQLite string literals".tag(expensiveTest)) { + withConnection("jdbc:sqlite::memory:") { conn => + val dialect = Dialect.sqlite.given_Dialect + interestingStrings.foreach(input => testStringLiteral(dialect, conn, input)) + } + } + + test("H2 string literals".tag(expensiveTest)) { + withConnection("jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1") { conn => + val dialect = Dialect.h2.given_Dialect + interestingStrings.foreach(input => testStringLiteral(dialect, conn, input)) + } + } + + test("DuckDB string literals".tag(expensiveTest)) { + withConnection("jdbc:duckdb:") { conn => + val dialect = Dialect.duckdb.given_Dialect + interestingStrings.foreach(input => testStringLiteral(dialect, conn, input)) + } + } +} diff --git a/src/test/scala/test/query/AggregationTests.scala b/src/test/scala/test/query/AggregationTests.scala index e8262f6..e61cc6d 100644 --- a/src/test/scala/test/query/AggregationTests.scala +++ b/src/test/scala/test/query/AggregationTests.scala @@ -97,8 +97,7 @@ class AggregateMultiSubexpression2AggregateTest extends SQLStringAggregationTest ) def expectedQueryPattern: String = - """SELECT AVG(product$A.price) > product$A.price = "true" as avg FROM product as product$A - """ + """SELECT AVG(product$A.price) > product$A.price = TRUE as avg FROM product as product$A""" } // Query helper-method based aggregation: diff --git a/src/test/scala/test/query/JoinTests.scala b/src/test/scala/test/query/JoinTests.scala index d73c553..3449edb 100644 --- a/src/test/scala/test/query/JoinTests.scala +++ b/src/test/scala/test/query/JoinTests.scala @@ -57,7 +57,7 @@ class JoinSimple3Test extends SQLStringQueryTest[AllCommerceDBs, (buyerName: Str FROM buyers as buyers$B, product as product$P - WHERE buyers$B.name = "string constant" + WHERE buyers$B.name = 'string constant' """ } @@ -74,7 +74,7 @@ class JoinSimple4Test extends SQLStringQueryTest[AllCommerceDBs, (buyerName: Str def expectedQueryPattern: String = """ SELECT buyers$A.name as buyerName, product$B.name as productName, product$B.price as price FROM buyers as buyers$A, product as product$B -WHERE (buyers$A.name = "string constant" AND product$B.id = buyers$A.id) +WHERE (buyers$A.name = 'string constant' AND product$B.id = buyers$A.id) """ } // TODO: Not implemented yet, Join flavors, cross/left/etc diff --git a/src/test/scala/test/query/RecursiveBenchmarkTests.scala b/src/test/scala/test/query/RecursiveBenchmarkTests.scala index fb86408..bc23b8c 100644 --- a/src/test/scala/test/query/RecursiveBenchmarkTests.scala +++ b/src/test/scala/test/query/RecursiveBenchmarkTests.scala @@ -645,7 +645,7 @@ class AncestryTest extends SQLStringQueryTest[AncestryDB, (name: String)] { recursive$162 AS ((SELECT parents$162.child as name, 1 as gen FROM parents as parents$162 - WHERE parents$162.parent = "Alice") + WHERE parents$162.parent = 'Alice') UNION ((SELECT parents$164.child as name, ref$86.gen + 1 as gen FROM parents as parents$164, recursive$162 as ref$86 @@ -685,19 +685,19 @@ class EvenOddTest extends SQLStringQueryTest[EvenOddDB, NumberType] { """ WITH RECURSIVE recursive$1 AS - ((SELECT numbers$2.value as value, "even" as typ + ((SELECT numbers$2.value as value, 'even' as typ FROM numbers as numbers$2 WHERE numbers$2.value = 0) UNION - ((SELECT numbers$4.value as value, "even" as typ + ((SELECT numbers$4.value as value, 'even' as typ FROM numbers as numbers$4, recursive$2 as ref$5 WHERE numbers$4.value = ref$5.value + 1))), recursive$2 AS - ((SELECT numbers$8.value as value, "odd" as typ + ((SELECT numbers$8.value as value, 'odd' as typ FROM numbers as numbers$8 WHERE numbers$8.value = 1) UNION - ((SELECT numbers$10.value as value, "odd" as typ + ((SELECT numbers$10.value as value, 'odd' as typ FROM numbers as numbers$10, recursive$1 as ref$8 WHERE numbers$10.value = ref$8.value + 1))) SELECT * FROM recursive$2 as recref$1 @@ -806,15 +806,15 @@ class CBATest extends SQLStringAggregationTest[CBADB, Int] { recursive$40 AS ((SELECT term$43.x as x, lits$44.y as y FROM term as term$43, lits as lits$44 - WHERE lits$44.x = term$43.z AND term$43.y = "Lit") + WHERE lits$44.x = term$43.z AND term$43.y = 'Lit') UNION ALL ((SELECT term$47.x as x, ref$28.y as y FROM term as term$47, recursive$41 as ref$28 - WHERE term$47.y = "Var" AND term$47.z = ref$28.x) + WHERE term$47.y = 'Var' AND term$47.z = ref$28.x) UNION ALL (SELECT term$50.x as x, ref$31.y as y FROM term as term$50, recursive$40 as ref$31, recursive$42 as ref$32, abs as abs$51, app as app$52 - WHERE term$50.y = "App" AND term$50.z = app$52.x AND ref$31.x = abs$51.z AND ref$32.x = app$52.y AND ref$32.y = abs$51.x))), + WHERE term$50.y = 'App' AND term$50.z = app$52.x AND ref$31.x = abs$51.z AND ref$32.x = app$52.y AND ref$32.y = abs$51.x))), recursive$41 AS ((SELECT * FROM baseData as baseData$60) UNION ALL @@ -823,15 +823,15 @@ class CBATest extends SQLStringAggregationTest[CBADB, Int] { WHERE ref$36.x = app$63.y AND ref$36.y = abs$62.x AND ref$37.x = app$63.z))), recursive$42 AS ((SELECT term$69.x as x, term$69.z as y - FROM term as term$69 WHERE term$69.y = "Abs") + FROM term as term$69 WHERE term$69.y = 'Abs') UNION ALL ((SELECT term$71.x as x, ref$42.y as y FROM term as term$71, recursive$43 as ref$42 - WHERE term$71.y = "Var" AND term$71.z = ref$42.x) + WHERE term$71.y = 'Var' AND term$71.z = ref$42.x) UNION ALL (SELECT term$74.x as x, ref$45.y as y FROM term as term$74, recursive$42 as ref$45, recursive$42 as ref$46, abs as abs$75, app as app$76 - WHERE term$74.y = "App" AND term$74.z = app$76.x AND ref$45.x = abs$75.z AND ref$46.x = app$76.y AND ref$46.y = abs$75.x))), + WHERE term$74.y = 'App' AND term$74.z = app$76.x AND ref$45.x = abs$75.z AND ref$46.x = app$76.y AND ref$46.y = abs$75.x))), recursive$43 AS ((SELECT * FROM baseCtrl as baseCtrl$84) UNION ALL @@ -1047,7 +1047,7 @@ class PointsToCountTest extends SQLStringAggregationTest[PointsToDB, Int] { ((SELECT ref$9.y as x, store$15.y as y, ref$10.y as h FROM store as store$15, recursive$1 as ref$9, recursive$1 as ref$10 WHERE store$15.x = ref$9.x AND store$15.h = ref$10.x))) - SELECT COUNT(1) FROM recursive$1 as recref$0 WHERE recref$0.x = "r" + SELECT COUNT(1) FROM recursive$1 as recref$0 WHERE recref$0.x = 'r' """ } diff --git a/src/test/scala/test/query/RecursiveTests.scala b/src/test/scala/test/query/RecursiveTests.scala index af95449..3d6bea2 100644 --- a/src/test/scala/test/query/RecursiveTests.scala +++ b/src/test/scala/test/query/RecursiveTests.scala @@ -677,7 +677,7 @@ class RecursionTreeTest extends SQLStringQueryTest[TagDB, List[String]] { tag$64.id as id, tag$64.name as source, list_prepend(tag$64.name, ref$30.path) as path FROM recursive$62 as ref$30, tag as tag$64 WHERE tag$64.subclassof = ref$30.id))) - SELECT recref$5.path FROM recursive$62 as recref$5 WHERE recref$5.source = "Oasis" + SELECT recref$5.path FROM recursive$62 as recref$5 WHERE recref$5.source = 'Oasis' """ } diff --git a/src/test/scala/test/query/SubqueryTests.scala b/src/test/scala/test/query/SubqueryTests.scala index b16b2f0..2eb8a1c 100644 --- a/src/test/scala/test/query/SubqueryTests.scala +++ b/src/test/scala/test/query/SubqueryTests.scala @@ -541,7 +541,7 @@ class NestedJoinSubquery7Test extends SQLStringQueryTest[AllCommerceDBs, Product FROM purchase as purchase$A, product as product$B - WHERE product$B.name = "test" + WHERE product$B.name = 'test' """ } @@ -560,7 +560,7 @@ class NestedJoinSubquery8Test extends SQLStringQueryTest[AllCommerceDBs, Product FROM purchase as purchase$A, product as product$B - WHERE (purchase$A.id = 1 AND product$B.name = "test") + WHERE (purchase$A.id = 1 AND product$B.name = 'test') """ } @@ -580,7 +580,7 @@ class NestedJoinSubquery9Test extends SQLStringQueryTest[AllCommerceDBs, (newId: FROM purchase as purchase$A, product as product$B - WHERE product$B.name = "test" + WHERE product$B.name = 'test' """ } @@ -717,7 +717,7 @@ class NestedJoinSubquery15Test extends SQLStringQueryTest[AllCommerceDBs, Produc FROM (SELECT purchase$B.id as newId FROM purchase as purchase$B) as subquery$C, product as product$A - WHERE product$A.name = "test" + WHERE product$A.name = 'test' """ } @@ -1557,7 +1557,7 @@ class NestedJoinExtraTest extends SQLStringQueryTest[AllCommerceDBs, (name: Stri purchase as purchase$B, product as product$A) as subquery$C - WHERE subquery$C.name = "test" + WHERE subquery$C.name = 'test' """ } @@ -1580,7 +1580,7 @@ class NestedJoinExtra2Test extends SQLStringQueryTest[AllCommerceDBs, (name: Str FROM purchase as purchase$B, product as product$A - WHERE (purchase$B.id = 1 AND product$A.name = "test") + WHERE (purchase$B.id = 1 AND product$A.name = 'test') """ } From ec61dcf1a710248932372fa79b9b0c48c41b6f49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Mon, 18 Nov 2024 10:08:40 +0100 Subject: [PATCH 011/106] Optimized string literal quoting --- src/main/scala/tyql/dialects/string literals.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/scala/tyql/dialects/string literals.scala b/src/main/scala/tyql/dialects/string literals.scala index 9f22193..6f9341a 100644 --- a/src/main/scala/tyql/dialects/string literals.scala +++ b/src/main/scala/tyql/dialects/string literals.scala @@ -9,7 +9,7 @@ object StringLiteral: // ESCAPE is needed for ANSI, H2, DuckDB, not needed in PostgreSQL, SQLite // still, it's safer to emit since all these backend have configuration options // MySQL, MariaDB REJECT the ESCAPE keyword in LIKE patterns - if insideLikePattern && (in.contains(Dialect.literal_percent) || in.contains(Dialect.literal_underscore)) then + if insideLikePattern && in.exists(c => c == Dialect.literal_percent || c == Dialect.literal_underscore) then (in.replace(Dialect.literal_percent.toString, "\\%").replace(Dialect.literal_underscore.toString, "\\_"), true) else (in.replace(Dialect.literal_percent, '%').replace(Dialect.literal_underscore, '_'), false) From 1c2043a589125db702fb33a2e7c280e3e1afbd4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Mon, 18 Nov 2024 11:01:13 +0100 Subject: [PATCH 012/106] Helper funtions to invoke DBs --- src/test/scala/test/InvokeDBs.scala | 101 ++++++++++++++++++ .../scala/test/dialects/string literals.scala | 71 +++++------- .../scala/test/integration/DBsAreLive.scala | 25 ++--- 3 files changed, 136 insertions(+), 61 deletions(-) create mode 100644 src/test/scala/test/InvokeDBs.scala diff --git a/src/test/scala/test/InvokeDBs.scala b/src/test/scala/test/InvokeDBs.scala new file mode 100644 index 0000000..d92670c --- /dev/null +++ b/src/test/scala/test/InvokeDBs.scala @@ -0,0 +1,101 @@ +package test + +import munit.FunSuite +import tyql.Dialect +import java.sql.{Connection, DriverManager} +import test.expensiveTest + +private def withConnection[A](url: String, user: String = "", password: String = "")(d: Dialect)(f: Connection => Dialect ?=> A): A = { + var conn: Connection = null + try { + conn = DriverManager.getConnection(url, user, password) + f(conn)(using d) + } finally { + if (conn != null) conn.close() + } +} + +object withDB: + def postgres[A](f: Connection => Dialect ?=> A): A = { + withConnection( + "jdbc:postgresql://localhost:5433/testdb", + "testuser", + "testpass" + )(tyql.Dialect.postgresql.given_Dialect)(f) + } + + def mysql[A](f: Connection => Dialect ?=> A): A = { + given Dialect = tyql.Dialect.mysql.given_Dialect + withConnection( + "jdbc:mysql://localhost:3307/testdb", + "testuser", + "testpass" + )(tyql.Dialect.mysql.given_Dialect)(f) + } + + def mariadb[A](f: Connection => Dialect ?=> A)(using Dialect): A = { + given Dialect = tyql.Dialect.mariadb.given_Dialect + withConnection( + "jdbc:mariadb://localhost:3308/testdb", + "testuser", + "testpass" + )(tyql.Dialect.mariadb.given_Dialect)(f) + } + + def sqlite[A](f: Connection => Dialect ?=> A)(using Dialect): A = { + given Dialect = tyql.Dialect.sqlite.given_Dialect + withConnection( + "jdbc:sqlite::memory:" + )(tyql.Dialect.sqlite.given_Dialect)(f) + } + + def duckdb[A](f: Connection => Dialect ?=> A): A = { + given Dialect = tyql.Dialect.duckdb.given_Dialect + withConnection( + "jdbc:duckdb:" + )(tyql.Dialect.duckdb.given_Dialect)(f) + } + + def h2[A](f: Connection => Dialect ?=> A): A = { + given Dialect = tyql.Dialect.h2.given_Dialect + withConnection( + "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1" + )(tyql.Dialect.h2.given_Dialect)(f) + } + +class WithDBHelpersTest extends FunSuite { + private def checkSelect10(conn: Connection) = { + val stmt = conn.createStatement() + val rs = stmt.executeQuery("SELECT 10 as x") + assert(rs.next()) + val x = rs.getInt("x") + assertEquals(x, 10) + } + + test("dialect selection works with withDB helper methods".tag(expensiveTest)) { + withDB.postgres { conn => + checkSelect10(conn) + assertEquals(summon[Dialect].name(), "PostgreSQL Dialect") + } + withDB.sqlite { conn => + checkSelect10(conn) + assertEquals(summon[Dialect].name(), "SQLite Dialect") + } + withDB.mysql { conn => + checkSelect10(conn) + assertEquals(summon[Dialect].name(), "MySQL Dialect") + } + withDB.mariadb { conn => + checkSelect10(conn) + assertEquals(summon[Dialect].name(), "MariaDB Dialect") + } + withDB.h2 { conn => + checkSelect10(conn) + assertEquals(summon[Dialect].name(), "H2 Dialect") + } + withDB.duckdb { conn => + checkSelect10(conn) + assertEquals(summon[Dialect].name(), "DuckDB Dialect") + } + } +} diff --git a/src/test/scala/test/dialects/string literals.scala b/src/test/scala/test/dialects/string literals.scala index d9904c7..2d2bb7b 100644 --- a/src/test/scala/test/dialects/string literals.scala +++ b/src/test/scala/test/dialects/string literals.scala @@ -3,20 +3,11 @@ package test.integration.stringliteralescaping import munit.FunSuite import test.expensiveTest import tyql.Dialect +import test.withDB import java.sql.{Connection, DriverManager} class StringLiteralDBTest extends FunSuite { - def withConnection[A](url: String, user: String = "", password: String = "")(f: Connection => A): A = { - var conn: Connection = null - try { - conn = DriverManager.getConnection(url, user, password) - f(conn) - } finally { - if (conn != null) conn.close() - } - } - - private def testStringLiteral(dialect: Dialect, conn: Connection, input: String) = { + private def testStringLiteral(conn: Connection, input: String)(using dialect: Dialect) = { val quoted = dialect.quoteStringLiteral(input, insideLikePattern=false) val stmt = conn.createStatement() val rs = stmt.executeQuery(s"SELECT $quoted as str") @@ -43,7 +34,7 @@ class StringLiteralDBTest extends FunSuite { assertEquals(Dialect.sqlite.given_Dialect.quoteStringLiteral("a\nb", insideLikePattern=false), "'a\nb'"); } - private def testLikePatterns(dialect: Dialect, conn: Connection) = { + private def testLikePatterns(conn: Connection)(using dialect: Dialect) = { val stmt = conn.createStatement() val underscorePattern = dialect.quoteStringLiteral("a_c", insideLikePattern=true) @@ -80,77 +71,71 @@ class StringLiteralDBTest extends FunSuite { } test("PostgreSQL LIKE patterns".tag(expensiveTest)) { - withConnection("jdbc:postgresql://localhost:5433/testdb", "testuser", "testpass") { conn => - testLikePatterns(Dialect.postgresql.given_Dialect, conn) - } + withDB.postgres( { conn => + testLikePatterns(conn) + }) } test("MySQL LIKE patterns".tag(expensiveTest)) { - withConnection("jdbc:mysql://localhost:3307/testdb", "testuser", "testpass") { conn => - testLikePatterns(Dialect.mysql.given_Dialect, conn) + withDB.mysql { conn => + testLikePatterns(conn) } } test("MariaDB LIKE patterns".tag(expensiveTest)) { - withConnection("jdbc:mariadb://localhost:3308/testdb", "testuser", "testpass") { conn => - testLikePatterns(Dialect.mariadb.given_Dialect, conn) + withDB.mariadb { conn => + testLikePatterns(conn) } } test("SQLite LIKE patterns".tag(expensiveTest)) { - withConnection("jdbc:sqlite::memory:") { conn => - testLikePatterns(Dialect.sqlite.given_Dialect, conn) + withDB.sqlite { conn => + testLikePatterns(conn) } } test("H2 LIKE patterns".tag(expensiveTest)) { - withConnection("jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1") { conn => - testLikePatterns(Dialect.h2.given_Dialect, conn) + withDB.h2 { conn => + testLikePatterns(conn) } } test("DuckDB LIKE patterns".tag(expensiveTest)) { - withConnection("jdbc:duckdb:") { conn => - testLikePatterns(Dialect.duckdb.given_Dialect, conn) + withDB.duckdb { conn => + testLikePatterns(conn) } } test("PostgreSQL string literals".tag(expensiveTest)) { - withConnection("jdbc:postgresql://localhost:5433/testdb", "testuser", "testpass") { conn => - val dialect = Dialect.postgresql.given_Dialect - interestingStrings.foreach(input => testStringLiteral(dialect, conn, input)) + withDB.postgres { conn => + interestingStrings.foreach(input => testStringLiteral(conn, input)) } } test("MySQL and MariaDB string literals".tag(expensiveTest)) { - withConnection("jdbc:mysql://localhost:3307/testdb", "testuser", "testpass") { conn => - val dialect = Dialect.mysql.given_Dialect - interestingStrings.foreach(input => testStringLiteral(dialect, conn, input)) + withDB.mysql { conn => + interestingStrings.foreach(input => testStringLiteral(conn, input)) } - withConnection("jdbc:mariadb://localhost:3308/testdb", "testuser", "testpass") { conn => - val dialect = Dialect.mariadb.given_Dialect - interestingStrings.foreach(input => testStringLiteral(dialect, conn, input)) + withDB.mariadb { conn => + interestingStrings.foreach(input => testStringLiteral(conn, input)) } } test("SQLite string literals".tag(expensiveTest)) { - withConnection("jdbc:sqlite::memory:") { conn => - val dialect = Dialect.sqlite.given_Dialect - interestingStrings.foreach(input => testStringLiteral(dialect, conn, input)) + withDB.sqlite { conn => + interestingStrings.foreach(input => testStringLiteral(conn, input)) } } test("H2 string literals".tag(expensiveTest)) { - withConnection("jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1") { conn => - val dialect = Dialect.h2.given_Dialect - interestingStrings.foreach(input => testStringLiteral(dialect, conn, input)) + withDB.h2 { conn => + interestingStrings.foreach(input => testStringLiteral(conn, input)) } } test("DuckDB string literals".tag(expensiveTest)) { - withConnection("jdbc:duckdb:") { conn => - val dialect = Dialect.duckdb.given_Dialect - interestingStrings.foreach(input => testStringLiteral(dialect, conn, input)) + withDB.duckdb { conn => + interestingStrings.foreach(input => testStringLiteral(conn, input)) } } } diff --git a/src/test/scala/test/integration/DBsAreLive.scala b/src/test/scala/test/integration/DBsAreLive.scala index b15e28e..eb482c1 100644 --- a/src/test/scala/test/integration/DBsAreLive.scala +++ b/src/test/scala/test/integration/DBsAreLive.scala @@ -4,6 +4,7 @@ import munit.FunSuite import test.expensiveTest import java.sql.{Connection, DriverManager} +import test.withDB class DBsAreLive extends FunSuite { def withConnection[A](url: String, user: String = "", password: String = "")(f: Connection => A): A = { @@ -17,11 +18,7 @@ class DBsAreLive extends FunSuite { } test("PostgreSQL responds".tag(expensiveTest)) { - withConnection( - "jdbc:postgresql://localhost:5433/testdb", - "testuser", - "testpass" - ) { conn => + withDB.postgres { conn => val stmt = conn.createStatement() val rs = stmt.executeQuery("SELECT ARRAY[5,10,3]::integer[] as arr") assert(rs.next()) @@ -31,11 +28,7 @@ class DBsAreLive extends FunSuite { } test("MySQL responds".tag(expensiveTest)) { - withConnection( - "jdbc:mysql://localhost:3307/testdb", - "testuser", - "testpass" - ) { conn => + withDB.mysql { conn => val stmt = conn.createStatement() val rs = stmt.executeQuery( """SELECT GROUP_CONCAT(n ORDER BY n SEPARATOR '-') as concat @@ -47,11 +40,7 @@ class DBsAreLive extends FunSuite { } test("MariaDB responds".tag(expensiveTest)) { - withConnection( - "jdbc:mariadb://localhost:3308/testdb", - "testuser", - "testpass" - ) { conn => + withDB.mariadb { conn => val stmt = conn.createStatement() val rs = stmt.executeQuery( """SELECT seq FROM seq_1_to_4""" @@ -65,7 +54,7 @@ class DBsAreLive extends FunSuite { } test("SQLite responds".tag(expensiveTest)) { - withConnection("jdbc:sqlite::memory:") { conn => + withDB.sqlite { conn => val stmt = conn.createStatement() val rs = stmt.executeQuery( """SELECT value FROM json_each('[5,55,3]') ORDER BY value""" @@ -79,7 +68,7 @@ class DBsAreLive extends FunSuite { } test("DuckDB responds".tag(expensiveTest)) { - withConnection("jdbc:duckdb:") { conn => + withDB.duckdb { conn => val stmt = conn.createStatement() val rs = stmt.executeQuery( """SELECT * FROM generate_series(1, 7, 2) as g""" @@ -93,7 +82,7 @@ class DBsAreLive extends FunSuite { } test("H2 responds".tag(expensiveTest)) { - withConnection("jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1") { conn => + withDB.h2 { conn => val stmt = conn.createStatement() val rs1 = stmt.executeQuery("""SELECT CASEWHEN(1=1, 'yes', 'no') as result""") From 52c774a97b1c640ffffa31ff138abc45b95c3b56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Mon, 18 Nov 2024 11:14:10 +0100 Subject: [PATCH 013/106] Refactor LIKE string quoting tests for clarity --- .../scala/test/dialects/string literals.scala | 41 +++++++------------ 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/src/test/scala/test/dialects/string literals.scala b/src/test/scala/test/dialects/string literals.scala index 2d2bb7b..9681859 100644 --- a/src/test/scala/test/dialects/string literals.scala +++ b/src/test/scala/test/dialects/string literals.scala @@ -39,35 +39,24 @@ class StringLiteralDBTest extends FunSuite { val underscorePattern = dialect.quoteStringLiteral("a_c", insideLikePattern=true) val literalUnderscorePattern = dialect.quoteStringLiteral(s"a${Dialect.literal_underscore}c", insideLikePattern=true) - - val rs1 = stmt.executeQuery(s"SELECT 'abc' LIKE $underscorePattern as result") - assert(rs1.next()) - assert(rs1.getBoolean("result")) - val rs2 = stmt.executeQuery(s"SELECT 'a_c' LIKE $underscorePattern as result") - assert(rs2.next()) - assert(rs2.getBoolean("result")) - val rs3 = stmt.executeQuery(s"SELECT 'abc' LIKE $literalUnderscorePattern as result") - assert(rs3.next()) - assert(!rs3.getBoolean("result")) - val rs4 = stmt.executeQuery(s"SELECT 'a_c' LIKE $literalUnderscorePattern as result") - assert(rs4.next()) - assert(rs4.getBoolean("result"), "the pattern was " + s"SELECT 'a_c' LIKE $literalUnderscorePattern as result") - val percentPattern = dialect.quoteStringLiteral("a%c", insideLikePattern=true) val literalPercentPattern = dialect.quoteStringLiteral(s"a${Dialect.literal_percent}c", insideLikePattern=true) - val rs5 = stmt.executeQuery(s"SELECT 'abc' LIKE $percentPattern as result") - assert(rs5.next()) - assert(rs5.getBoolean("result")) - val rs6 = stmt.executeQuery(s"SELECT 'a%c' LIKE $percentPattern as result") - assert(rs6.next()) - assert(rs6.getBoolean("result")) - val rs7 = stmt.executeQuery(s"SELECT 'abc' LIKE $literalPercentPattern as result") - assert(rs7.next()) - assert(!rs7.getBoolean("result")) - val rs8 = stmt.executeQuery(s"SELECT 'a%c' LIKE $literalPercentPattern as result") - assert(rs8.next()) - assert(rs8.getBoolean("result")) + def check(query: String, expectedResult: Boolean) = { + val rs = stmt.executeQuery(query + " as did_match") + assert(rs.next()) + assertEquals(rs.getBoolean("did_match"), expectedResult) + } + + check(s"SELECT 'abc' LIKE $underscorePattern", true); + check(s"SELECT 'a_c' LIKE $underscorePattern", true); + check(s"SELECT 'abc' LIKE $literalUnderscorePattern", false); + check(s"SELECT 'a_c' LIKE $literalUnderscorePattern", true); + + check(s"SELECT 'abc' LIKE $percentPattern", true); + check(s"SELECT 'a%c' LIKE $percentPattern", true); + check(s"SELECT 'abc' LIKE $literalPercentPattern", false); + check(s"SELECT 'a%c' LIKE $literalPercentPattern", true); } test("PostgreSQL LIKE patterns".tag(expensiveTest)) { From e4e0942881df4d4adca4a98029c60e4dbb3b24c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Mon, 18 Nov 2024 13:18:21 +0100 Subject: [PATCH 014/106] Config (for now just case conversions), fix implicit propagation --- src/main/scala/tyql/config.scala | 29 +++++++ src/main/scala/tyql/ir/QueryIRNode.scala | 30 +++---- src/main/scala/tyql/ir/RelationOp.scala | 22 ++--- src/main/scala/tyql/query/DatabaseAST.scala | 3 +- .../scala/test/config/case convention.scala | 83 +++++++++++++++++++ 5 files changed, 141 insertions(+), 26 deletions(-) create mode 100644 src/main/scala/tyql/config.scala create mode 100644 src/test/scala/test/config/case convention.scala diff --git a/src/main/scala/tyql/config.scala b/src/main/scala/tyql/config.scala new file mode 100644 index 0000000..8e10f1e --- /dev/null +++ b/src/main/scala/tyql/config.scala @@ -0,0 +1,29 @@ +package tyql + +// TODO Quill also allows you to chain a few of them and offers uppercase and lowercase options +// https://github.com/fwbrasil/quill/#naming-strategy + +enum CaseConvention: + case Exact + case Underscores // three_letter_word + case PascalCase // ThreeLetterWord + case CamelCase // threeLetterWord + + def convert(name: String): String = + val parts = CaseConvention.splitName(name) + this match + case Exact => name + case Underscores => parts.mkString("_") + case PascalCase => parts.map(_.capitalize).mkString + case CamelCase => parts.head + parts.tail.map(_.capitalize).mkString + +object CaseConvention: + private def splitName(name: String): List[String] = + name.split("(?=[A-Z])|[_\\s]").filter(_.nonEmpty).map(_.toLowerCase).toList + +trait Config ( + val caseConvention: CaseConvention +) + +object Config: + given Config = new Config(CaseConvention.Exact) {} diff --git a/src/main/scala/tyql/ir/QueryIRNode.scala b/src/main/scala/tyql/ir/QueryIRNode.scala index 08952bc..c477c59 100644 --- a/src/main/scala/tyql/ir/QueryIRNode.scala +++ b/src/main/scala/tyql/ir/QueryIRNode.scala @@ -6,7 +6,7 @@ package tyql trait QueryIRNode: val ast: DatabaseAST[?] | Expr[?, ?] | Expr.Fun[?, ?, ?] // Best-effort, keep AST around for debugging, TODO: probably remove, or replace only with ResultTag - def toSQLString(): String + def toSQLString(using d: Dialect)(using cnf: Config)(): String trait QueryIRLeaf extends QueryIRNode @@ -14,21 +14,21 @@ trait QueryIRLeaf extends QueryIRNode * Single WHERE clause containing 1+ predicates */ case class WhereClause(children: Seq[QueryIRNode], ast: Expr[?, ?]) extends QueryIRNode: - override def toSQLString(): String = if children.size == 1 then children.head.toSQLString() else s"${children.map(_.toSQLString()).mkString("", " AND ", "")}" + override def toSQLString(using d: Dialect)(using cnf: Config)(): String = if children.size == 1 then children.head.toSQLString() else s"${children.map(_.toSQLString()).mkString("", " AND ", "")}" /** * Binary expression-level operation. * TODO: cannot assume the operation is universal, need to specialize for DB backend */ case class BinExprOp(lhs: QueryIRNode, rhs: QueryIRNode, op: (String, String) => String, ast: Expr[?, ?]) extends QueryIRNode: - override def toSQLString(): String = op(lhs.toSQLString(), rhs.toSQLString()) + override def toSQLString(using d: Dialect)(using cnf: Config)(): String = op(lhs.toSQLString(), rhs.toSQLString()) /** * Unary expression-level operation. * TODO: cannot assume the operation is universal, need to specialize for DB backend */ case class UnaryExprOp(child: QueryIRNode, op: String => String, ast: Expr[?, ?]) extends QueryIRNode: - override def toSQLString(): String = op(s"${child.toSQLString()}") + override def toSQLString(using d: Dialect)(using cnf: Config)(): String = op(s"${child.toSQLString()}") /** * Project clause, e.g. SELECT <...> FROM @@ -36,7 +36,7 @@ case class UnaryExprOp(child: QueryIRNode, op: String => String, ast: Expr[?, ?] * @param ast */ case class ProjectClause(children: Seq[QueryIRNode], ast: Expr[?, ?]) extends QueryIRNode: - override def toSQLString(): String = children.map(_.toSQLString()).mkString("", ", ", "") + override def toSQLString(using d: Dialect)(using cnf: Config)(): String = children.map(_.toSQLString()).mkString("", ", ", "") /** @@ -48,34 +48,36 @@ case class AttrExpr(child: QueryIRNode, projectedName: Option[String], ast: Expr val asStr = projectedName match case Some(value) => s" as ${d.quoteIdentifier(value)}" case None => "" - override def toSQLString(): String = s"${child.toSQLString()}$asStr" + override def toSQLString(using d: Dialect)(using cnf: Config)(): String = s"${child.toSQLString()}$asStr" /** * Attribute access expression, e.g. `table.rowName`. */ -case class SelectExpr(attrName: String, from: QueryIRNode, ast: Expr[?, ?])(using d: Dialect) extends QueryIRLeaf: - override def toSQLString(): String = s"${from.toSQLString()}.${d.quoteIdentifier(attrName)}" +case class SelectExpr(attrName: String, from: QueryIRNode, ast: Expr[?, ?]) extends QueryIRLeaf: + override def toSQLString(using d: Dialect)(using cnf: Config)(): String = + s"${from.toSQLString()}.${d.quoteIdentifier(cnf.caseConvention.convert(attrName))}" /** * A variable that points to a table or subquery. */ -case class QueryIRVar(toSub: RelationOp, name: String, ast: Expr.Ref[?, ?])(using d: Dialect) extends QueryIRLeaf: - override def toSQLString() = d.quoteIdentifier(toSub.alias) +case class QueryIRVar(toSub: RelationOp, name: String, ast: Expr.Ref[?, ?]) extends QueryIRLeaf: + override def toSQLString(using d: Dialect)(using cnf: Config)() = + d.quoteIdentifier(cnf.caseConvention.convert(toSub.alias)) - override def toString: String = s"VAR(${toSub.alias}.${d.quoteIdentifier(name)})" + override def toString: String = s"VAR(${toSub.alias}.${name})" // TODO what about this? /** * Literals. * TODO: can't assume stringRep is universal, need to specialize for DB backend. */ case class Literal(stringRep: String, ast: Expr[?, ?]) extends QueryIRLeaf: - override def toSQLString(): String = stringRep + override def toSQLString(using d: Dialect)(using cnf: Config)(): String = stringRep case class ListTypeExpr(elements: List[QueryIRNode], ast: Expr[?, ?]) extends QueryIRNode: - override def toSQLString(): String = elements.map(_.toSQLString()).mkString("[", ", ", "]") + override def toSQLString(using d: Dialect)(using cnf: Config)(): String = elements.map(_.toSQLString()).mkString("[", ", ", "]") /** * Empty leaf node, to avoid Options everywhere. */ case class EmptyLeaf(ast: DatabaseAST[?] = null) extends QueryIRLeaf: - override def toSQLString(): String = "" \ No newline at end of file + override def toSQLString(using d: Dialect)(using cnf: Config)(): String = "" diff --git a/src/main/scala/tyql/ir/RelationOp.scala b/src/main/scala/tyql/ir/RelationOp.scala index 9774e39..c80aa30 100644 --- a/src/main/scala/tyql/ir/RelationOp.scala +++ b/src/main/scala/tyql/ir/RelationOp.scala @@ -71,11 +71,13 @@ case class TableLeaf(tableName: String, ast: Table[?]) extends RelationOp with Q val name = s"$tableName${QueryIRTree.idCount}" QueryIRTree.idCount += 1 override def alias = name - override def toSQLString(): String = + override def toSQLString(using d: Dialect)(using cnf: Config)(): String = + val escapedTableName = d.quoteIdentifier(cnf.caseConvention.convert(tableName)) + val escapedAlias = d.quoteIdentifier(cnf.caseConvention.convert(name)) // TODO does it break something? Think about it if (flags.contains(SelectFlags.Final)) - tableName + escapedTableName else - s"$tableName as $name" + s"$escapedTableName as $escapedAlias" override def toString: String = s"TableLeaf($tableName as $name)" @@ -158,7 +160,7 @@ case class SelectAllQuery(from: Seq[RelationOp], flags = flags + f this - override def toSQLString(): String = + override def toSQLString(using d: Dialect)(using cnf: Config)(): String = val flagsStr = if flags.contains(SelectFlags.Distinct) then "DISTINCT " else "" val fromStr = from.map(f => f.toSQLString()).mkString("", ", ", "") val whereStr = if where.nonEmpty then @@ -214,7 +216,7 @@ case class SelectQuery(project: QueryIRNode, flags = flags + f this - override def toSQLString(): String = + override def toSQLString(using d: Dialect)(using cnf: Config)(): String = val flagsStr = if flags.contains(SelectFlags.Distinct) then "DISTINCT " else "" val projectStr = project.toSQLString() val fromStr = from.map(f => f.toSQLString()).mkString("", ", ", "") @@ -297,7 +299,7 @@ case class OrderedQuery(query: RelationOp, sortFn: Seq[(QueryIRNode, Ord)], ast: flags = flags + f this - override def toSQLString(): String = + override def toSQLString(using d: Dialect)(using cnf: Config)(): String = wrapString(s"${query.toSQLString()} ORDER BY ${sortFn.map(s => val varStr = s._1 match // NOTE: special case orderBy alias since for now, don't bother prefixing, TODO: which prefix to use for multi-relation select? case v: SelectExpr => v.attrName @@ -366,7 +368,7 @@ case class NaryRelationOp(children: Seq[QueryIRNode], op: String, ast: DatabaseA flags = flags + f this - override def toSQLString(): String = + override def toSQLString(using d: Dialect)(using cnf: Config)(): String = wrapString(children.map(_.toSQLString()).mkString(s" $op ")) case class MultiRecursiveRelationOp(aliases: Seq[String], @@ -398,7 +400,7 @@ case class MultiRecursiveRelationOp(aliases: Seq[String], flags = flags + f this - override def toSQLString(): String = + override def toSQLString(using d: Dialect)(using cnf: Config)(): String = // NOTE: no parens or alias needed, since already defined val ctes = aliases.zip(query).map((a, q) => s"$a AS (${q.toSQLString()})").mkString(",\n") s"WITH RECURSIVE $ctes\n ${finalQ.toSQLString()}" @@ -407,7 +409,7 @@ case class MultiRecursiveRelationOp(aliases: Seq[String], * A recursive variable that points to a table or subquery. */ case class RecursiveIRVar(pointsToAlias: String, alias: String, ast: DatabaseAST[?]) extends RelationOp: - override def toSQLString() = s"$pointsToAlias as $alias" + override def toSQLString(using d: Dialect)(using cnf: Config)() = s"$pointsToAlias as $alias" override def toString: String = s"RVAR($alias->$pointsToAlias)" // TODO: for now reuse TableOp's methods @@ -475,7 +477,7 @@ case class GroupByQuery( flags = flags + f this - override def toSQLString(): String = + override def toSQLString(using d: Dialect)(using cnf: Config)(): String = val flagsStr = if flags.contains(SelectFlags.Distinct) then "DISTINCT " else "" val sourceStr = source.toSQLString() val groupByStr = groupBy.toSQLString() diff --git a/src/main/scala/tyql/query/DatabaseAST.scala b/src/main/scala/tyql/query/DatabaseAST.scala index f09983f..2631b59 100644 --- a/src/main/scala/tyql/query/DatabaseAST.scala +++ b/src/main/scala/tyql/query/DatabaseAST.scala @@ -5,8 +5,7 @@ package tyql * @tparam Result */ trait DatabaseAST[Result](using val qTag: ResultTag[Result]): - def toSQLString: String = toQueryIR.toSQLString() + def toSQLString(using d: Dialect)(using cnf: Config): String = toQueryIR.toSQLString() def toQueryIR: QueryIRNode = QueryIRTree.generateFullQuery(this, SymbolTable()) - diff --git a/src/test/scala/test/config/case convention.scala b/src/test/scala/test/config/case convention.scala new file mode 100644 index 0000000..c7867c9 --- /dev/null +++ b/src/test/scala/test/config/case convention.scala @@ -0,0 +1,83 @@ +package test.config.caseconvention + +import munit.FunSuite +import tyql.{Config, Dialect} +import tyql.CaseConvention +import test.withDB +import test.expensiveTest + +import tyql.{Table, DatabaseAST, Query} +import scala.language.experimental.namedTuples +import NamedTuple.{NamedTuple, AnyNamedTuple} + +class CaseConventionTests extends FunSuite { + private val expectations = Seq( + ("aa bb cc", "aa_bb_cc", "aaBbCc", "AaBbCc"), + ("aabbcc", "aabbcc", "aabbcc", "Aabbcc"), + ("aaBb_cc", "aa_bb_cc", "aaBbCc", "AaBbCc"), + ("AaBbCc", "aa_bb_cc", "aaBbCc", "AaBbCc"), + ("abc12", "abc12", "abc12", "Abc12"), + ("abc_12", "abc_12", "abc12", "Abc12"), + ("abC12", "ab_c12", "abC12", "AbC12"), + ("ABC", "a_b_c", "aBC", "ABC"), + ) + + test("expected case conversions") { + for (e <- expectations) { + val (in, underscores, camelCase, pascalCase) = e + assertEquals(CaseConvention.Exact.convert(in), in) + assertEquals(CaseConvention.Underscores.convert(in), underscores) + assertEquals(CaseConvention.CamelCase.convert(in), camelCase) + assertEquals(CaseConvention.PascalCase.convert(in), pascalCase) + } + } + + test("by default exact case conversion") { + def check()(using c: Config) = { + assertEquals(c.caseConvention.convert("aa bb cc"), "aa bb cc") + assertEquals(c.caseConvention.convert("aaBb_cc"), "aaBb_cc") + } + check() + } + + test("you can select a different case conversion") { + def check()(using c: Config) = { + assertEquals(c.caseConvention.convert("aa bb cc"), "aaBbCc") + assertEquals(c.caseConvention.convert("aaBb_cc"), "aaBbCc") + } + given Config = new Config(caseConvention = CaseConvention.CamelCase) {} + check() + } + + test("postgres handles it".tag(expensiveTest)) { + withDB.postgres{ conn => + + def check(tableName: String, columnName: String)(using cnf: Config) = { + val escapedTableName = summon[Dialect].quoteIdentifier(tableName) + val escapedColunmName = summon[Dialect].quoteIdentifier(columnName) + + case class Tbl(id: Int, aaBb_Cc: String) + val q = Table[Tbl](tableName).map(b => b.aaBb_Cc) + val stmt = conn.createStatement() + try { + stmt.executeUpdate(s"drop table if exists ${escapedTableName};") + stmt.executeUpdate(s"create table ${escapedTableName}(${escapedColunmName} int);") + stmt.executeUpdate(s"insert into ${escapedTableName} (${escapedColunmName}) values (117);") + val r = stmt.executeQuery(q.toQueryIR.toSQLString()) + assert(r.next()) + assertEquals(r.getInt(cnf.caseConvention.convert(columnName)), 117) + stmt.executeUpdate(s"drop table ${escapedTableName};") + } + catch { + case e: Exception => throw e + } + finally stmt.close() + } + + check("caseCon ventionTests 19471", "aaBb_Cc")(using new Config(CaseConvention.Exact) {}) + check("caseConventionTests19471", "aaBbCc")(using new Config(CaseConvention.CamelCase) {}) + check("CaseConventionTests19471", "AaBbCc")(using new Config(CaseConvention.PascalCase) {}) + check("case_convention_tests19471", "aa_bb_cc")(using new Config(CaseConvention.Underscores) {}) + } + } +} From 265e5185d2aa4cdd4f87eef6f0f5df265fcf08dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Mon, 18 Nov 2024 13:29:12 +0100 Subject: [PATCH 015/106] DB test: add parametrization over all backends --- src/test/scala/test/InvokeDBs.scala | 14 ++++ .../scala/test/dialects/string literals.scala | 65 ++----------------- 2 files changed, 18 insertions(+), 61 deletions(-) diff --git a/src/test/scala/test/InvokeDBs.scala b/src/test/scala/test/InvokeDBs.scala index d92670c..f48f5e2 100644 --- a/src/test/scala/test/InvokeDBs.scala +++ b/src/test/scala/test/InvokeDBs.scala @@ -63,6 +63,20 @@ object withDB: )(tyql.Dialect.h2.given_Dialect)(f) } + def all[A](f: Connection => Dialect ?=> A): Unit = { + postgres(f) + mysql(f) + mariadb(f) + sqlite(f) + duckdb(f) + h2(f) + } + + def allmysql[A](f: Connection => Dialect ?=> A): Unit = { + mysql(f) + mariadb(f) + } + class WithDBHelpersTest extends FunSuite { private def checkSelect10(conn: Connection) = { val stmt = conn.createStatement() diff --git a/src/test/scala/test/dialects/string literals.scala b/src/test/scala/test/dialects/string literals.scala index 9681859..a128bee 100644 --- a/src/test/scala/test/dialects/string literals.scala +++ b/src/test/scala/test/dialects/string literals.scala @@ -59,71 +59,14 @@ class StringLiteralDBTest extends FunSuite { check(s"SELECT 'a%c' LIKE $literalPercentPattern", true); } - test("PostgreSQL LIKE patterns".tag(expensiveTest)) { - withDB.postgres( { conn => + test("all DBs handle LIKE patterns".tag(expensiveTest)) { + withDB.all { conn => testLikePatterns(conn) - }) - } - - test("MySQL LIKE patterns".tag(expensiveTest)) { - withDB.mysql { conn => - testLikePatterns(conn) - } - } - - test("MariaDB LIKE patterns".tag(expensiveTest)) { - withDB.mariadb { conn => - testLikePatterns(conn) - } - } - - test("SQLite LIKE patterns".tag(expensiveTest)) { - withDB.sqlite { conn => - testLikePatterns(conn) - } - } - - test("H2 LIKE patterns".tag(expensiveTest)) { - withDB.h2 { conn => - testLikePatterns(conn) - } - } - - test("DuckDB LIKE patterns".tag(expensiveTest)) { - withDB.duckdb { conn => - testLikePatterns(conn) - } - } - - test("PostgreSQL string literals".tag(expensiveTest)) { - withDB.postgres { conn => - interestingStrings.foreach(input => testStringLiteral(conn, input)) - } - } - - test("MySQL and MariaDB string literals".tag(expensiveTest)) { - withDB.mysql { conn => - interestingStrings.foreach(input => testStringLiteral(conn, input)) - } - withDB.mariadb { conn => - interestingStrings.foreach(input => testStringLiteral(conn, input)) - } - } - - test("SQLite string literals".tag(expensiveTest)) { - withDB.sqlite { conn => - interestingStrings.foreach(input => testStringLiteral(conn, input)) - } - } - - test("H2 string literals".tag(expensiveTest)) { - withDB.h2 { conn => - interestingStrings.foreach(input => testStringLiteral(conn, input)) } } - test("DuckDB string literals".tag(expensiveTest)) { - withDB.duckdb { conn => + test("all DBs handle string literals".tag(expensiveTest)) { + withDB.all { conn => interestingStrings.foreach(input => testStringLiteral(conn, input)) } } From d9b901f00d65ca29ad955c36ae0d3fc2981c6b51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Mon, 18 Nov 2024 13:38:10 +0100 Subject: [PATCH 016/106] Rename munit tag `expensiveTest` -> `needsDBs` --- README.md | 4 ++-- src/test/scala/test/InvokeDBs.scala | 4 ++-- src/test/scala/test/Tags.scala | 2 +- src/test/scala/test/config/case convention.scala | 4 ++-- src/test/scala/test/dialects/string literals.scala | 6 +++--- src/test/scala/test/integration/DBsAreLive.scala | 14 +++++++------- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index eb02202..835ca66 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ ### Running Tests Tests are untagged by default or tagged as expensive. ```scala -import test.expensiveTest -test("PostgreSQL responds".tag(expensiveTest)) { ??? } +import test.needsDBs +test("PostgreSQL responds".tag(needsDBs)) { ??? } ``` ```bash diff --git a/src/test/scala/test/InvokeDBs.scala b/src/test/scala/test/InvokeDBs.scala index f48f5e2..cb4ac29 100644 --- a/src/test/scala/test/InvokeDBs.scala +++ b/src/test/scala/test/InvokeDBs.scala @@ -3,7 +3,7 @@ package test import munit.FunSuite import tyql.Dialect import java.sql.{Connection, DriverManager} -import test.expensiveTest +import test.needsDBs private def withConnection[A](url: String, user: String = "", password: String = "")(d: Dialect)(f: Connection => Dialect ?=> A): A = { var conn: Connection = null @@ -86,7 +86,7 @@ class WithDBHelpersTest extends FunSuite { assertEquals(x, 10) } - test("dialect selection works with withDB helper methods".tag(expensiveTest)) { + test("dialect selection works with withDB helper methods".tag(needsDBs)) { withDB.postgres { conn => checkSelect10(conn) assertEquals(summon[Dialect].name(), "PostgreSQL Dialect") diff --git a/src/test/scala/test/Tags.scala b/src/test/scala/test/Tags.scala index 2ef4b30..f3c7edc 100644 --- a/src/test/scala/test/Tags.scala +++ b/src/test/scala/test/Tags.scala @@ -2,4 +2,4 @@ package test import munit.Tag -val expensiveTest = new munit.Tag("Expensive") +val needsDBs = new munit.Tag("Expensive") diff --git a/src/test/scala/test/config/case convention.scala b/src/test/scala/test/config/case convention.scala index c7867c9..513d613 100644 --- a/src/test/scala/test/config/case convention.scala +++ b/src/test/scala/test/config/case convention.scala @@ -4,7 +4,7 @@ import munit.FunSuite import tyql.{Config, Dialect} import tyql.CaseConvention import test.withDB -import test.expensiveTest +import test.needsDBs import tyql.{Table, DatabaseAST, Query} import scala.language.experimental.namedTuples @@ -49,7 +49,7 @@ class CaseConventionTests extends FunSuite { check() } - test("postgres handles it".tag(expensiveTest)) { + test("postgres handles it".tag(needsDBs)) { withDB.postgres{ conn => def check(tableName: String, columnName: String)(using cnf: Config) = { diff --git a/src/test/scala/test/dialects/string literals.scala b/src/test/scala/test/dialects/string literals.scala index a128bee..639b461 100644 --- a/src/test/scala/test/dialects/string literals.scala +++ b/src/test/scala/test/dialects/string literals.scala @@ -1,7 +1,7 @@ package test.integration.stringliteralescaping import munit.FunSuite -import test.expensiveTest +import test.needsDBs import tyql.Dialect import test.withDB import java.sql.{Connection, DriverManager} @@ -59,13 +59,13 @@ class StringLiteralDBTest extends FunSuite { check(s"SELECT 'a%c' LIKE $literalPercentPattern", true); } - test("all DBs handle LIKE patterns".tag(expensiveTest)) { + test("all DBs handle LIKE patterns".tag(needsDBs)) { withDB.all { conn => testLikePatterns(conn) } } - test("all DBs handle string literals".tag(expensiveTest)) { + test("all DBs handle string literals".tag(needsDBs)) { withDB.all { conn => interestingStrings.foreach(input => testStringLiteral(conn, input)) } diff --git a/src/test/scala/test/integration/DBsAreLive.scala b/src/test/scala/test/integration/DBsAreLive.scala index eb482c1..6f50cf2 100644 --- a/src/test/scala/test/integration/DBsAreLive.scala +++ b/src/test/scala/test/integration/DBsAreLive.scala @@ -1,7 +1,7 @@ package test.integration.dbsresponding import munit.FunSuite -import test.expensiveTest +import test.needsDBs import java.sql.{Connection, DriverManager} import test.withDB @@ -17,7 +17,7 @@ class DBsAreLive extends FunSuite { } } - test("PostgreSQL responds".tag(expensiveTest)) { + test("PostgreSQL responds".tag(needsDBs)) { withDB.postgres { conn => val stmt = conn.createStatement() val rs = stmt.executeQuery("SELECT ARRAY[5,10,3]::integer[] as arr") @@ -27,7 +27,7 @@ class DBsAreLive extends FunSuite { } } - test("MySQL responds".tag(expensiveTest)) { + test("MySQL responds".tag(needsDBs)) { withDB.mysql { conn => val stmt = conn.createStatement() val rs = stmt.executeQuery( @@ -39,7 +39,7 @@ class DBsAreLive extends FunSuite { } } - test("MariaDB responds".tag(expensiveTest)) { + test("MariaDB responds".tag(needsDBs)) { withDB.mariadb { conn => val stmt = conn.createStatement() val rs = stmt.executeQuery( @@ -53,7 +53,7 @@ class DBsAreLive extends FunSuite { } } - test("SQLite responds".tag(expensiveTest)) { + test("SQLite responds".tag(needsDBs)) { withDB.sqlite { conn => val stmt = conn.createStatement() val rs = stmt.executeQuery( @@ -67,7 +67,7 @@ class DBsAreLive extends FunSuite { } } - test("DuckDB responds".tag(expensiveTest)) { + test("DuckDB responds".tag(needsDBs)) { withDB.duckdb { conn => val stmt = conn.createStatement() val rs = stmt.executeQuery( @@ -81,7 +81,7 @@ class DBsAreLive extends FunSuite { } } - test("H2 responds".tag(expensiveTest)) { + test("H2 responds".tag(needsDBs)) { withDB.h2 { conn => val stmt = conn.createStatement() From 10074404b3be011288cc0da64cc88c20a37a84dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Mon, 18 Nov 2024 13:42:46 +0100 Subject: [PATCH 017/106] test: we can send and receive unicode --- src/test/scala/test/dialects/string literals.scala | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/test/scala/test/dialects/string literals.scala b/src/test/scala/test/dialects/string literals.scala index 639b461..88586dc 100644 --- a/src/test/scala/test/dialects/string literals.scala +++ b/src/test/scala/test/dialects/string literals.scala @@ -19,14 +19,19 @@ class StringLiteralDBTest extends FunSuite { "a'b", "a\"b", "a\\b", - "a\bb", // Backspace + "a\bb", // Backspace "a\fb", "a\nb", "a\rb", "a\tb", "a\u001Ab", // Ctrl+Z "a%b", // LIKE wildcard % - "a_b" // LIKE wildcard _ + "a_b", // LIKE wildcard _ + "éèêëàâäôöûüùïîçæœ ÉÈÊËÀÂÄÔÖÛÜÙÏÎÇÆŒ", // French + "äöüßÄÖÜ", // German + "ąćęłńóśźżĄĆĘŁŃÓŚŹŻ", // Polish + "абвгдеёжзийклмнопрстуфхцчшщъыьэюяАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ", // Russian + "αβγδεζηθικλμνξοπρστυφχψωΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩάέήίόύώΆΈΉΊΌΎΏ", // Greek ) test("string literals are handled per dialect") { From a3b70ed6901d1afe6866ffd97a9fd6a9d172cb40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Mon, 18 Nov 2024 15:37:55 +0100 Subject: [PATCH 018/106] A comment --- src/main/scala/tyql/dialects/dialects.scala | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index 9501df1..edd20e0 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -1,5 +1,7 @@ package tyql +// TODO which of these should be sealed? Do we support custom dialectes? + trait Dialect: def name(): String From 4d71194dbd15ca35fda9629d3dc0bfef0301b356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Mon, 18 Nov 2024 15:38:40 +0100 Subject: [PATCH 019/106] whitespace --- src/test/scala/test/config/case convention.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/scala/test/config/case convention.scala b/src/test/scala/test/config/case convention.scala index 513d613..47029d5 100644 --- a/src/test/scala/test/config/case convention.scala +++ b/src/test/scala/test/config/case convention.scala @@ -58,7 +58,7 @@ class CaseConventionTests extends FunSuite { case class Tbl(id: Int, aaBb_Cc: String) val q = Table[Tbl](tableName).map(b => b.aaBb_Cc) - val stmt = conn.createStatement() + val stmt = conn.createStatement() try { stmt.executeUpdate(s"drop table if exists ${escapedTableName};") stmt.executeUpdate(s"create table ${escapedTableName}(${escapedColunmName} int);") From 6b1aa545266d953f26e5fbadfc6503cd441616f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Mon, 18 Nov 2024 15:45:59 +0100 Subject: [PATCH 020/106] tests: db connection helpers also need a version without implicits for precision --- src/test/scala/test/InvokeDBs.scala | 79 +++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/src/test/scala/test/InvokeDBs.scala b/src/test/scala/test/InvokeDBs.scala index cb4ac29..fc0b79e 100644 --- a/src/test/scala/test/InvokeDBs.scala +++ b/src/test/scala/test/InvokeDBs.scala @@ -15,6 +15,16 @@ private def withConnection[A](url: String, user: String = "", password: String = } } +private def withConnectionNoImplicits[A](url: String, user: String = "", password: String = "")(f: Connection => A): A = { + var conn: Connection = null + try { + conn = DriverManager.getConnection(url, user, password) + f(conn) + } finally { + if (conn != null) conn.close() + } +} + object withDB: def postgres[A](f: Connection => Dialect ?=> A): A = { withConnection( @@ -77,6 +87,68 @@ object withDB: mariadb(f) } +object withDBNoImplicits: + def postgres[A](f: Connection => A): A = { + withConnectionNoImplicits( + "jdbc:postgresql://localhost:5433/testdb", + "testuser", + "testpass" + )(f) + } + + def mysql[A](f: Connection => A): A = { + given Dialect = tyql.Dialect.mysql.given_Dialect + withConnectionNoImplicits( + "jdbc:mysql://localhost:3307/testdb", + "testuser", + "testpass" + )(f) + } + + def mariadb[A](f: Connection => A)(using Dialect): A = { + given Dialect = tyql.Dialect.mariadb.given_Dialect + withConnectionNoImplicits( + "jdbc:mariadb://localhost:3308/testdb", + "testuser", + "testpass" + )(f) + } + + def sqlite[A](f: Connection => A)(using Dialect): A = { + given Dialect = tyql.Dialect.sqlite.given_Dialect + withConnectionNoImplicits( + "jdbc:sqlite::memory:" + )(f) + } + + def duckdb[A](f: Connection => A): A = { + given Dialect = tyql.Dialect.duckdb.given_Dialect + withConnectionNoImplicits( + "jdbc:duckdb:" + )(f) + } + + def h2[A](f: Connection => A): A = { + given Dialect = tyql.Dialect.h2.given_Dialect + withConnectionNoImplicits( + "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1" + )(f) + } + + def all[A](f: Connection => A): Unit = { + postgres(f) + mysql(f) + mariadb(f) + sqlite(f) + duckdb(f) + h2(f) + } + + def allmysql[A](f: Connection => A): Unit = { + mysql(f) + mariadb(f) + } + class WithDBHelpersTest extends FunSuite { private def checkSelect10(conn: Connection) = { val stmt = conn.createStatement() @@ -112,4 +184,11 @@ class WithDBHelpersTest extends FunSuite { assertEquals(summon[Dialect].name(), "DuckDB Dialect") } } + + test("withDBNoImplicits helper methods do not inject implicits".tag(needsDBs)) { + withDBNoImplicits.all { conn => + checkSelect10(conn) + assertEquals(summon[Dialect].name(), "ANSI SQL Dialect") + } + } } From 014e7dd30442f25d81a7795082813e80b40aedad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Mon, 18 Nov 2024 21:10:13 +0100 Subject: [PATCH 021/106] Dialect feature: fetch random float between 0 and 1 --- .../tyql/dialects/dialect features.scala | 6 ++ src/main/scala/tyql/dialects/dialects.scala | 12 ++++ src/main/scala/tyql/expr/Expr.scala | 27 +++++++-- src/main/scala/tyql/ir/QueryIRNode.scala | 4 ++ src/main/scala/tyql/ir/QueryIRTree.scala | 3 + src/main/scala/tyql/query/Query.scala | 1 + src/test/scala/test/integration/random.scala | 59 +++++++++++++++++++ 7 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 src/main/scala/tyql/dialects/dialect features.scala create mode 100644 src/test/scala/test/integration/random.scala diff --git a/src/main/scala/tyql/dialects/dialect features.scala b/src/main/scala/tyql/dialects/dialect features.scala new file mode 100644 index 0000000..66d1af7 --- /dev/null +++ b/src/main/scala/tyql/dialects/dialect features.scala @@ -0,0 +1,6 @@ +package tyql + +trait DialectFeature + +object DialectFeature: + trait RandomFloat(val funName: String) extends DialectFeature diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index edd20e0..6c10d2c 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -1,5 +1,8 @@ package tyql +import tyql.DialectFeature +import tyql.DialectFeature.* + // TODO which of these should be sealed? Do we support custom dialectes? trait Dialect: @@ -34,6 +37,7 @@ object Dialect: with StringLiteral.PostgresqlBehavior with BooleanLiterals.UseTrueFalse: def name() = "PostgreSQL Dialect" + given RandomFloat = new RandomFloat("random") {} object mysql: given Dialect = new MySQLDialect @@ -44,11 +48,15 @@ object Dialect: with BooleanLiterals.UseTrueFalse: def name() = "MySQL Dialect" + given RandomFloat = new RandomFloat("rand") {} + object mariadb: // XXX MariaDB extends MySQL! given Dialect = new mysql.MySQLDialect with QuotingIdentifiers.MariadbBehavior: override def name() = "MariaDB Dialect" + given RandomFloat = mysql.given_RandomFloat + object sqlite: given Dialect = new Dialect with QuotingIdentifiers.SqliteBehavior @@ -65,6 +73,8 @@ object Dialect: with BooleanLiterals.UseTrueFalse: def name() = "H2 Dialect" + given RandomFloat = new RandomFloat("rand") {} + object duckdb: given Dialect = new Dialect with QuotingIdentifiers.DuckdbBehavior @@ -72,3 +82,5 @@ object Dialect: with StringLiteral.DuckdbBehavior with BooleanLiterals.UseTrueFalse: override def name(): String = "DuckDB Dialect" + + given RandomFloat = new RandomFloat("random") {} diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index 72d62f1..995dda0 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -5,6 +5,7 @@ import language.experimental.namedTuples import NamedTuple.{AnyNamedTuple, NamedTuple} import scala.deriving.* import scala.compiletime.{erasedValue, summonInline} +import tyql.DialectFeature // TODO: probably seal trait ExprShape @@ -46,9 +47,15 @@ trait Expr[Result, Shape <: ExprShape](using val tag: ResultTag[Result]) extends object Expr: /** Sample extension methods for individual types */ extension [S1 <: ExprShape](x: Expr[Int, S1]) - def >[S2 <: ExprShape] (y: Expr[Int, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = Gt(x, y) + // def >[S2 <: ExprShape](y: Expr[Int, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = Gt(x, y) + def >(y: Expr[Int, ScalarExpr]): Expr[Boolean, CalculatedShape[S1, ScalarExpr]] = Gt(x, y) + @targetName("gtIntNonscalar") + def >(y: Expr[Int, NonScalarExpr]): Expr[Boolean, CalculatedShape[S1, NonScalarExpr]] = Gt(x, y) def >(y: Int): Expr[Boolean, S1] = Gt[S1, NonScalarExpr](x, IntLit(y)) - def <[S2 <: ExprShape] (y: Expr[Int, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = Lt(x, y) + // def <[S2 <: ExprShape] (y: Expr[Int, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = Lt(x, y) + def <(y: Expr[Int, ScalarExpr]): Expr[Boolean, CalculatedShape[S1, ScalarExpr]] = Lt(x, y) + @targetName("ltIntNonscalar") + def <(y: Expr[Int, NonScalarExpr]): Expr[Boolean, CalculatedShape[S1, NonScalarExpr]] = Lt(x, y) def <(y: Int): Expr[Boolean, S1] = Lt[S1, NonScalarExpr](x, IntLit(y)) def <=[S2 <: ExprShape] (y: Expr[Int, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = Lte(x, y) @@ -57,10 +64,10 @@ object Expr: // TODO: write for numerical extension [S1 <: ExprShape](x: Expr[Double, S1]) - @targetName("gtDoubleExpr") - def >[S2 <: ExprShape](y: Expr[Double, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = - GtDouble(x, y) - @targetName("gtDoubleLit") + @targetName("gtDoubleScalar") + def >(y: Expr[Double, ScalarExpr]): Expr[Boolean, CalculatedShape[S1, ScalarExpr]] = GtDouble(x, y) + @targetName("gtDoubleNonScalar") + def >(y: Expr[Double, NonScalarExpr]): Expr[Boolean, CalculatedShape[S1, NonScalarExpr]] = GtDouble(x, y) def >(y: Double): Expr[Boolean, S1] = GtDouble[S1, NonScalarExpr](x, DoubleLit(y)) def <(y: Double): Expr[Boolean, S1] = LtDouble[S1, NonScalarExpr](x, DoubleLit(y)) @targetName("addDouble") @@ -114,6 +121,10 @@ object Expr: case class GtDouble[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Double, S1], $y: Expr[Double, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] case class LtDouble[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Double, S1], $y: Expr[Double, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] + case class FunctionCall0[R](name: String)(using ResultTag[R]) extends Expr[R, NonScalarExpr] // XXX TODO NonScalarExpr? + case class FunctionCall1[A1, R, S1 <: ExprShape](name: String, $a1: Expr[A1, S1])(using ResultTag[R]) extends Expr[R, S1] + case class FunctionCall2[A1, A2, R, S1 <: ExprShape, S2 <: ExprShape](name: String, $a1: Expr[A1, S1], $a2: Expr[A2, S2])(using ResultTag[R]) extends Expr[R, CalculatedShape[S1, S2]] + case class Plus[S1 <: ExprShape, S2 <: ExprShape, T: Numeric]($x: Expr[T, S1], $y: Expr[T, S2])(using ResultTag[T]) extends Expr[T, CalculatedShape[S1, S2]] case class Times[S1 <: ExprShape, S2 <: ExprShape, T: Numeric]($x: Expr[T, S1], $y: Expr[T, S2])(using ResultTag[T]) extends Expr[T, CalculatedShape[S1, S2]] case class And[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Boolean, S1], $y: Expr[Boolean, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] @@ -178,6 +189,7 @@ object Expr: case class IntLit($value: Int) extends Expr[Int, NonScalarExpr] /** Scala values can be lifted into literals by conversions */ given Conversion[Int, IntLit] = IntLit(_) + // XXX maybe only from literals with FromDigits? case class StringLit($value: String) extends Expr[String, NonScalarExpr] given Conversion[String, StringLit] = StringLit(_) @@ -189,6 +201,9 @@ object Expr: // given Conversion[Boolean, BooleanLit] = BooleanLit(_) // TODO why does this break things? + def randomFloat(using r: DialectFeature.RandomFloat)(): Expr[Double, NonScalarExpr] = + FunctionCall0[Double](r.funName) + /** Should be able to rely on the implicit conversions, but not always. * One approach is to overload, another is to provide a user-facing toExpr * function. diff --git a/src/main/scala/tyql/ir/QueryIRNode.scala b/src/main/scala/tyql/ir/QueryIRNode.scala index c477c59..d61acd3 100644 --- a/src/main/scala/tyql/ir/QueryIRNode.scala +++ b/src/main/scala/tyql/ir/QueryIRNode.scala @@ -30,6 +30,10 @@ case class BinExprOp(lhs: QueryIRNode, rhs: QueryIRNode, op: (String, String) => case class UnaryExprOp(child: QueryIRNode, op: String => String, ast: Expr[?, ?]) extends QueryIRNode: override def toSQLString(using d: Dialect)(using cnf: Config)(): String = op(s"${child.toSQLString()}") +case class FunctionCallOp(name: String, children: Seq[QueryIRNode], ast: Expr[?, ?]) extends QueryIRNode: + override def toSQLString(using d: Dialect)(using cnf: Config)(): String = s"$name(" + children.map(_.toSQLString()).mkString(", ") + ")" + // TODO does this need ()s sometimes? + /** * Project clause, e.g. SELECT <...> FROM * @param children diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index 55e5533..0b3a0ff 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -345,6 +345,9 @@ object QueryIRTree: case g: Expr.LtDouble[?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l < $r", g) case a: Expr.And[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l AND $r", a) case n: Expr.Not[?] => UnaryExprOp(generateExpr(n.$x, symbols), o => s"NOT $o", n) + case f0: Expr.FunctionCall0[?] => FunctionCallOp(f0.name, Seq(), f0) + case f1: Expr.FunctionCall1[?, ?, ?] => FunctionCallOp(f1.name, Seq(generateExpr(f1.$a1, symbols)), f1) + case f2: Expr.FunctionCall2[?, ?, ?, ?, ?] => FunctionCallOp(f2.name, Seq(f2.$a1, f2.$a1).map(generateExpr(_, symbols)), f2) case a: Expr.Plus[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l + $r", a) case a: Expr.Times[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l * $r", a) case a: Expr.Eq[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l = $r", a) diff --git a/src/main/scala/tyql/query/Query.scala b/src/main/scala/tyql/query/Query.scala index 385f8d2..3eab6c7 100644 --- a/src/main/scala/tyql/query/Query.scala +++ b/src/main/scala/tyql/query/Query.scala @@ -232,6 +232,7 @@ trait Query[A, Category <: ResultCategory](using ResultTag[A]) extends DatabaseA val ref = Ref[A, NonScalarExpr]() Query.Filter(this, Fun(ref, p(ref))) + // TODO what about filtering by multiple variables? def filter(p: Ref[A, NonScalarExpr] => Expr[Boolean, NonScalarExpr]): Query[A, Category] = withFilter(p) def nonEmpty: Expr[Boolean, NonScalarExpr] = diff --git a/src/test/scala/test/integration/random.scala b/src/test/scala/test/integration/random.scala new file mode 100644 index 0000000..c476c64 --- /dev/null +++ b/src/test/scala/test/integration/random.scala @@ -0,0 +1,59 @@ +package test.integration.random + +import munit.FunSuite +import test.withDBNoImplicits +import java.sql.{Connection, Statement} +import tyql.{Dialect, Table, Expr} + +class RandomTests extends FunSuite { + test("Random test") { + + def init(stmt: Statement) = { + stmt.executeUpdate(s"DROP TABLE IF EXISTS table1733;") + stmt.executeUpdate(s"CREATE TABLE table1733 (i INTEGER);") + stmt.executeUpdate(s"INSERT INTO table1733 (i) VALUES (1);"); + } + + def clean(stmt: Statement) = { + stmt.executeUpdate(s"DROP TABLE IF EXISTS table1733;") + } + + case class Row(i: Int) + val t = Table[Row]("table1733") + + def check(sqlQuery: String)(runner: (f: Connection => Unit) => Unit): Unit = { + for (_ <- 1 to 3) { + runner { conn => + val stmt = conn.createStatement() + init(stmt) + val rs = stmt.executeQuery(sqlQuery) + assert(rs.next()) + val r = rs.getDouble(1) + assert(0 <= r && r <= 1) + clean(stmt) + } + } + } + + { + import Dialect.postgresql.given + check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString())(withDBNoImplicits.postgres) + } + { + import Dialect.mysql.given + check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString())(withDBNoImplicits.mysql) + } + { + import Dialect.mariadb.given + check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString())(withDBNoImplicits.mariadb) + } + { + import Dialect.duckdb.given + check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString())(withDBNoImplicits.duckdb) + } + { + import Dialect.h2.given + check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString())(withDBNoImplicits.h2) + } + } +} From 854c00cdcd8619acec858cac6763c08161749f68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Mon, 18 Nov 2024 21:33:03 +0100 Subject: [PATCH 022/106] docker: update info message --- dev.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev.sh b/dev.sh index acfca49..6063bec 100755 --- a/dev.sh +++ b/dev.sh @@ -16,7 +16,7 @@ case $command in ;; *) echo "Unknown command: $command" - echo "Usage: ./dev.sh [up|down|test]" + echo "Usage: ./dev.sh [db-start|db-stop|test [testClassName]]" exit 1 ;; esac From 81d80aef7c096ece0b2e8be60cb0ca82825b62d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 11:36:54 +0100 Subject: [PATCH 023/106] dialect: sqlite: add random float --- src/main/scala/tyql/dialects/dialect features.scala | 3 ++- src/main/scala/tyql/dialects/dialects.scala | 13 ++++++++----- src/main/scala/tyql/expr/Expr.scala | 9 ++++++++- src/main/scala/tyql/ir/QueryIRNode.scala | 3 +++ src/main/scala/tyql/ir/QueryIRTree.scala | 1 + src/test/scala/test/integration/random.scala | 4 ++++ 6 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/main/scala/tyql/dialects/dialect features.scala b/src/main/scala/tyql/dialects/dialect features.scala index 66d1af7..1549d90 100644 --- a/src/main/scala/tyql/dialects/dialect features.scala +++ b/src/main/scala/tyql/dialects/dialect features.scala @@ -3,4 +3,5 @@ package tyql trait DialectFeature object DialectFeature: - trait RandomFloat(val funName: String) extends DialectFeature + trait RandomFloat(val funName: Option[String], val rawSQL: Option[String] = None) extends DialectFeature: + assert(funName.isDefined == !rawSQL.isDefined) diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index 6c10d2c..47dab30 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -37,7 +37,7 @@ object Dialect: with StringLiteral.PostgresqlBehavior with BooleanLiterals.UseTrueFalse: def name() = "PostgreSQL Dialect" - given RandomFloat = new RandomFloat("random") {} + given RandomFloat = new RandomFloat(Some("random")) {} object mysql: given Dialect = new MySQLDialect @@ -48,10 +48,11 @@ object Dialect: with BooleanLiterals.UseTrueFalse: def name() = "MySQL Dialect" - given RandomFloat = new RandomFloat("rand") {} + given RandomFloat = new RandomFloat(Some("rand")) {} object mariadb: - // XXX MariaDB extends MySQL! + // XXX MariaDB extends MySQL + // XXX but you still have to redeclare the givens given Dialect = new mysql.MySQLDialect with QuotingIdentifiers.MariadbBehavior: override def name() = "MariaDB Dialect" @@ -65,6 +66,8 @@ object Dialect: with BooleanLiterals.UseTrueFalse: def name() = "SQLite Dialect" + given RandomFloat = new RandomFloat(None, Some("(0.5 - RANDOM() / CAST(-9223372036854775808 AS REAL) / 2)")) {} + object h2: given Dialect = new Dialect with QuotingIdentifiers.H2Behavior @@ -73,7 +76,7 @@ object Dialect: with BooleanLiterals.UseTrueFalse: def name() = "H2 Dialect" - given RandomFloat = new RandomFloat("rand") {} + given RandomFloat = new RandomFloat(Some("rand")) {} object duckdb: given Dialect = new Dialect @@ -83,4 +86,4 @@ object Dialect: with BooleanLiterals.UseTrueFalse: override def name(): String = "DuckDB Dialect" - given RandomFloat = new RandomFloat("random") {} + given RandomFloat = new RandomFloat(Some("random")) {} diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index 995dda0..d12d2ad 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -125,6 +125,8 @@ object Expr: case class FunctionCall1[A1, R, S1 <: ExprShape](name: String, $a1: Expr[A1, S1])(using ResultTag[R]) extends Expr[R, S1] case class FunctionCall2[A1, A2, R, S1 <: ExprShape, S2 <: ExprShape](name: String, $a1: Expr[A1, S1], $a2: Expr[A2, S2])(using ResultTag[R]) extends Expr[R, CalculatedShape[S1, S2]] + case class RawSQLInsert[R](sql: String)(using ResultTag[R]) extends Expr[R, NonScalarExpr] // XXX TODO NonScalarExpr? + case class Plus[S1 <: ExprShape, S2 <: ExprShape, T: Numeric]($x: Expr[T, S1], $y: Expr[T, S2])(using ResultTag[T]) extends Expr[T, CalculatedShape[S1, S2]] case class Times[S1 <: ExprShape, S2 <: ExprShape, T: Numeric]($x: Expr[T, S1], $y: Expr[T, S2])(using ResultTag[T]) extends Expr[T, CalculatedShape[S1, S2]] case class And[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Boolean, S1], $y: Expr[Boolean, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] @@ -202,7 +204,12 @@ object Expr: // TODO why does this break things? def randomFloat(using r: DialectFeature.RandomFloat)(): Expr[Double, NonScalarExpr] = - FunctionCall0[Double](r.funName) + if r.rawSQL.isDefined then + RawSQLInsert[Double](r.rawSQL.get) + else if r.funName.isDefined then + FunctionCall0[Double](r.funName.get) + else + assert(false, "RandomFloat dialect feature must have either a function name or raw SQL") /** Should be able to rely on the implicit conversions, but not always. * One approach is to overload, another is to provide a user-facing toExpr diff --git a/src/main/scala/tyql/ir/QueryIRNode.scala b/src/main/scala/tyql/ir/QueryIRNode.scala index d61acd3..8de7bdd 100644 --- a/src/main/scala/tyql/ir/QueryIRNode.scala +++ b/src/main/scala/tyql/ir/QueryIRNode.scala @@ -34,6 +34,9 @@ case class FunctionCallOp(name: String, children: Seq[QueryIRNode], ast: Expr[?, override def toSQLString(using d: Dialect)(using cnf: Config)(): String = s"$name(" + children.map(_.toSQLString()).mkString(", ") + ")" // TODO does this need ()s sometimes? +case class RawSQLInsertOp(sql: String, ast: Expr[?, ?]) extends QueryIRNode: + override def toSQLString(using d: Dialect)(using cnf: Config)(): String = sql + /** * Project clause, e.g. SELECT <...> FROM * @param children diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index 0b3a0ff..7f71419 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -348,6 +348,7 @@ object QueryIRTree: case f0: Expr.FunctionCall0[?] => FunctionCallOp(f0.name, Seq(), f0) case f1: Expr.FunctionCall1[?, ?, ?] => FunctionCallOp(f1.name, Seq(generateExpr(f1.$a1, symbols)), f1) case f2: Expr.FunctionCall2[?, ?, ?, ?, ?] => FunctionCallOp(f2.name, Seq(f2.$a1, f2.$a1).map(generateExpr(_, symbols)), f2) + case r: Expr.RawSQLInsert[?] => RawSQLInsertOp(r.sql, r) case a: Expr.Plus[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l + $r", a) case a: Expr.Times[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l * $r", a) case a: Expr.Eq[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l = $r", a) diff --git a/src/test/scala/test/integration/random.scala b/src/test/scala/test/integration/random.scala index c476c64..78c0684 100644 --- a/src/test/scala/test/integration/random.scala +++ b/src/test/scala/test/integration/random.scala @@ -55,5 +55,9 @@ class RandomTests extends FunSuite { import Dialect.h2.given check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString())(withDBNoImplicits.h2) } + { + import Dialect.sqlite.given + check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString())(withDBNoImplicits.sqlite) + } } } From c09831a9c10d6430b3016471e4fbb3674f64bb8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 11:47:46 +0100 Subject: [PATCH 024/106] dialect: add randomUUID() --- .../tyql/dialects/dialect features.scala | 1 + src/main/scala/tyql/dialects/dialects.scala | 5 + src/main/scala/tyql/expr/Expr.scala | 3 + src/test/scala/test/integration/random.scala | 95 +++++++++++++------ 4 files changed, 73 insertions(+), 31 deletions(-) diff --git a/src/main/scala/tyql/dialects/dialect features.scala b/src/main/scala/tyql/dialects/dialect features.scala index 1549d90..bf9da9d 100644 --- a/src/main/scala/tyql/dialects/dialect features.scala +++ b/src/main/scala/tyql/dialects/dialect features.scala @@ -5,3 +5,4 @@ trait DialectFeature object DialectFeature: trait RandomFloat(val funName: Option[String], val rawSQL: Option[String] = None) extends DialectFeature: assert(funName.isDefined == !rawSQL.isDefined) + trait RandomUUID(val funName: String) extends DialectFeature diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index 47dab30..0e8e382 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -38,6 +38,7 @@ object Dialect: with BooleanLiterals.UseTrueFalse: def name() = "PostgreSQL Dialect" given RandomFloat = new RandomFloat(Some("random")) {} + given RandomUUID = new RandomUUID("gen_random_uuid") {} object mysql: given Dialect = new MySQLDialect @@ -49,6 +50,7 @@ object Dialect: def name() = "MySQL Dialect" given RandomFloat = new RandomFloat(Some("rand")) {} + given RandomUUID = new RandomUUID("UUID") {} object mariadb: // XXX MariaDB extends MySQL @@ -57,6 +59,7 @@ object Dialect: override def name() = "MariaDB Dialect" given RandomFloat = mysql.given_RandomFloat + given RandomUUID = mysql.given_RandomUUID object sqlite: given Dialect = new Dialect @@ -77,6 +80,7 @@ object Dialect: def name() = "H2 Dialect" given RandomFloat = new RandomFloat(Some("rand")) {} + given RandomUUID = new RandomUUID("RANDOM_UUID") {} object duckdb: given Dialect = new Dialect @@ -87,3 +91,4 @@ object Dialect: override def name(): String = "DuckDB Dialect" given RandomFloat = new RandomFloat(Some("random")) {} + given RandomUUID = new RandomUUID("uuid") {} diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index d12d2ad..f011ef2 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -211,6 +211,9 @@ object Expr: else assert(false, "RandomFloat dialect feature must have either a function name or raw SQL") + def randomUUID(using r: DialectFeature.RandomUUID)(): Expr[String, NonScalarExpr] = + FunctionCall0[String](r.funName) + /** Should be able to rely on the implicit conversions, but not always. * One approach is to overload, another is to provide a user-facing toExpr * function. diff --git a/src/test/scala/test/integration/random.scala b/src/test/scala/test/integration/random.scala index 78c0684..56ad721 100644 --- a/src/test/scala/test/integration/random.scala +++ b/src/test/scala/test/integration/random.scala @@ -2,62 +2,95 @@ package test.integration.random import munit.FunSuite import test.withDBNoImplicits -import java.sql.{Connection, Statement} +import java.sql.{Connection, Statement, ResultSet} import tyql.{Dialect, Table, Expr} +import tyql.Subset.a class RandomTests extends FunSuite { - test("Random test") { - - def init(stmt: Statement) = { - stmt.executeUpdate(s"DROP TABLE IF EXISTS table1733;") - stmt.executeUpdate(s"CREATE TABLE table1733 (i INTEGER);") - stmt.executeUpdate(s"INSERT INTO table1733 (i) VALUES (1);"); - } + def init(stmt: Statement) = { + stmt.executeUpdate(s"DROP TABLE IF EXISTS table1733;") + stmt.executeUpdate(s"CREATE TABLE table1733 (i INTEGER);") + stmt.executeUpdate(s"INSERT INTO table1733 (i) VALUES (1);"); + } - def clean(stmt: Statement) = { - stmt.executeUpdate(s"DROP TABLE IF EXISTS table1733;") - } + def clean(stmt: Statement) = { + stmt.executeUpdate(s"DROP TABLE IF EXISTS table1733;") + } - case class Row(i: Int) - val t = Table[Row]("table1733") + case class Row(i: Int) + val t = Table[Row]("table1733") - def check(sqlQuery: String)(runner: (f: Connection => Unit) => Unit): Unit = { - for (_ <- 1 to 3) { - runner { conn => - val stmt = conn.createStatement() - init(stmt) - val rs = stmt.executeQuery(sqlQuery) - assert(rs.next()) - val r = rs.getDouble(1) - assert(0 <= r && r <= 1) - clean(stmt) - } + def check(sqlQuery: String, checkValue: ResultSet => Unit)(runner: (f: Connection => Unit) => Unit): Unit = { + for (_ <- 1 to 3) { + runner { conn => + val stmt = conn.createStatement() + init(stmt) + val rs = stmt.executeQuery(sqlQuery) + checkValue(rs) + clean(stmt) } } - + } + + test("randomFloat test") { + val checkValue = { (rs: ResultSet) => + assert(rs.next()) + val r = rs.getDouble(1) + assert(0 <= r && r <= 1) + } + { import Dialect.postgresql.given - check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString())(withDBNoImplicits.postgres) + check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.postgres) } { import Dialect.mysql.given - check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString())(withDBNoImplicits.mysql) + check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.mysql) } { import Dialect.mariadb.given - check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString())(withDBNoImplicits.mariadb) + check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.mariadb) } { import Dialect.duckdb.given - check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString())(withDBNoImplicits.duckdb) + check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.duckdb) } { import Dialect.h2.given - check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString())(withDBNoImplicits.h2) + check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.h2) } { import Dialect.sqlite.given - check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString())(withDBNoImplicits.sqlite) + check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.sqlite) + } + } + + test("randomUUID test") { + val checkValue = { (rs: ResultSet) => + assert(rs.next()) + val r = rs.getString(1) + assert(r.matches("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")) + } + + { + import Dialect.postgresql.given + check(t.map(_ => Expr.randomUUID()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.postgres) + } + { + import Dialect.mysql.given + check(t.map(_ => Expr.randomUUID()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.mysql) + } + { + import Dialect.mariadb.given + check(t.map(_ => Expr.randomUUID()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.mariadb) + } + { + import Dialect.duckdb.given + check(t.map(_ => Expr.randomUUID()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.duckdb) + } + { + import Dialect.h2.given + check(t.map(_ => Expr.randomUUID()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.h2) } } } From a9d736df08d2890904a9cfafd477a20097c858b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 12:22:48 +0100 Subject: [PATCH 025/106] dialect feature: randomInt(a, b) --- .../tyql/dialects/dialect features.scala | 1 + src/main/scala/tyql/dialects/dialects.scala | 6 +++ src/main/scala/tyql/expr/Expr.scala | 9 +++- src/main/scala/tyql/ir/QueryIRNode.scala | 7 +-- src/main/scala/tyql/ir/QueryIRTree.scala | 2 +- src/test/scala/test/integration/random.scala | 47 +++++++++++++++++++ 6 files changed, 67 insertions(+), 5 deletions(-) diff --git a/src/main/scala/tyql/dialects/dialect features.scala b/src/main/scala/tyql/dialects/dialect features.scala index bf9da9d..0d1533f 100644 --- a/src/main/scala/tyql/dialects/dialect features.scala +++ b/src/main/scala/tyql/dialects/dialect features.scala @@ -6,3 +6,4 @@ object DialectFeature: trait RandomFloat(val funName: Option[String], val rawSQL: Option[String] = None) extends DialectFeature: assert(funName.isDefined == !rawSQL.isDefined) trait RandomUUID(val funName: String) extends DialectFeature + trait RandomIntegerInInclusiveRange(val expr: (String, String) => String) extends DialectFeature // TODO later change it to not use raw SQL maybe? diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index 0e8e382..519f09a 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -39,6 +39,7 @@ object Dialect: def name() = "PostgreSQL Dialect" given RandomFloat = new RandomFloat(Some("random")) {} given RandomUUID = new RandomUUID("gen_random_uuid") {} + given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"floor(random() * ($b - $a + 1) + $a)::integer") {} object mysql: given Dialect = new MySQLDialect @@ -51,6 +52,7 @@ object Dialect: given RandomFloat = new RandomFloat(Some("rand")) {} given RandomUUID = new RandomUUID("UUID") {} + given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"floor(rand() * ($b - $a + 1) + $a)") {} object mariadb: // XXX MariaDB extends MySQL @@ -60,6 +62,7 @@ object Dialect: given RandomFloat = mysql.given_RandomFloat given RandomUUID = mysql.given_RandomUUID + given RandomIntegerInInclusiveRange = mysql.given_RandomIntegerInInclusiveRange object sqlite: given Dialect = new Dialect @@ -70,6 +73,7 @@ object Dialect: def name() = "SQLite Dialect" given RandomFloat = new RandomFloat(None, Some("(0.5 - RANDOM() / CAST(-9223372036854775808 AS REAL) / 2)")) {} + given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"cast(abs(random() % ($b - $a + 1) + $a) as integer)") {} // TODO think about how this impacts simplifications and efficient generation object h2: given Dialect = new Dialect @@ -81,6 +85,7 @@ object Dialect: given RandomFloat = new RandomFloat(Some("rand")) {} given RandomUUID = new RandomUUID("RANDOM_UUID") {} + given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"floor(rand() * ($b - $a + 1) + $a)") {} object duckdb: given Dialect = new Dialect @@ -92,3 +97,4 @@ object Dialect: given RandomFloat = new RandomFloat(Some("random")) {} given RandomUUID = new RandomUUID("uuid") {} + given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"floor(random() * ($b - $a + 1) + $a)::integer") {} diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index f011ef2..baae92c 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -125,7 +125,7 @@ object Expr: case class FunctionCall1[A1, R, S1 <: ExprShape](name: String, $a1: Expr[A1, S1])(using ResultTag[R]) extends Expr[R, S1] case class FunctionCall2[A1, A2, R, S1 <: ExprShape, S2 <: ExprShape](name: String, $a1: Expr[A1, S1], $a2: Expr[A2, S2])(using ResultTag[R]) extends Expr[R, CalculatedShape[S1, S2]] - case class RawSQLInsert[R](sql: String)(using ResultTag[R]) extends Expr[R, NonScalarExpr] // XXX TODO NonScalarExpr? + case class RawSQLInsert[R](sql: String, replacements: Map[String, Expr[?, ?]] = Map.empty)(using ResultTag[R]) extends Expr[R, NonScalarExpr] // XXX TODO NonScalarExpr? case class Plus[S1 <: ExprShape, S2 <: ExprShape, T: Numeric]($x: Expr[T, S1], $y: Expr[T, S2])(using ResultTag[T]) extends Expr[T, CalculatedShape[S1, S2]] case class Times[S1 <: ExprShape, S2 <: ExprShape, T: Numeric]($x: Expr[T, S1], $y: Expr[T, S2])(using ResultTag[T]) extends Expr[T, CalculatedShape[S1, S2]] @@ -214,6 +214,13 @@ object Expr: def randomUUID(using r: DialectFeature.RandomUUID)(): Expr[String, NonScalarExpr] = FunctionCall0[String](r.funName) + def randomInt(a: Expr[Int, ?], b: Expr[Int, ?])(using r: DialectFeature.RandomIntegerInInclusiveRange): Expr[Int, NonScalarExpr] = + // TODO maybe add a check for (a <= b) if we know both components at generation time? + // TODO what about parenehteses? Do we really not need them? + val aStr = "A82139520369" + val bStr = "B27604933360" + RawSQLInsert[Int](r.expr(aStr, bStr), Map(aStr -> a, bStr -> b)) + /** Should be able to rely on the implicit conversions, but not always. * One approach is to overload, another is to provide a user-facing toExpr * function. diff --git a/src/main/scala/tyql/ir/QueryIRNode.scala b/src/main/scala/tyql/ir/QueryIRNode.scala index 8de7bdd..071c8d7 100644 --- a/src/main/scala/tyql/ir/QueryIRNode.scala +++ b/src/main/scala/tyql/ir/QueryIRNode.scala @@ -34,11 +34,12 @@ case class FunctionCallOp(name: String, children: Seq[QueryIRNode], ast: Expr[?, override def toSQLString(using d: Dialect)(using cnf: Config)(): String = s"$name(" + children.map(_.toSQLString()).mkString(", ") + ")" // TODO does this need ()s sometimes? -case class RawSQLInsertOp(sql: String, ast: Expr[?, ?]) extends QueryIRNode: - override def toSQLString(using d: Dialect)(using cnf: Config)(): String = sql +case class RawSQLInsertOp(sql: String, replacements: Map[String, QueryIRNode], ast: Expr[?, ?]) extends QueryIRNode: + override def toSQLString(using d: Dialect)(using cnf: Config)(): String = + replacements.foldLeft(sql) { case (acc, (k, v)) => acc.replace(k, v.toSQLString()) } /** - * Project clause, e.g. SELECT <...> FROM + * Project clause, e.g. SELECT <...> FROM * @param children * @param ast */ diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index 7f71419..2c0ef8c 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -348,7 +348,7 @@ object QueryIRTree: case f0: Expr.FunctionCall0[?] => FunctionCallOp(f0.name, Seq(), f0) case f1: Expr.FunctionCall1[?, ?, ?] => FunctionCallOp(f1.name, Seq(generateExpr(f1.$a1, symbols)), f1) case f2: Expr.FunctionCall2[?, ?, ?, ?, ?] => FunctionCallOp(f2.name, Seq(f2.$a1, f2.$a1).map(generateExpr(_, symbols)), f2) - case r: Expr.RawSQLInsert[?] => RawSQLInsertOp(r.sql, r) + case r: Expr.RawSQLInsert[?] => RawSQLInsertOp(r.sql, r.replacements.mapValues(generateExpr(_, symbols)).toMap, r) case a: Expr.Plus[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l + $r", a) case a: Expr.Times[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l * $r", a) case a: Expr.Eq[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l = $r", a) diff --git a/src/test/scala/test/integration/random.scala b/src/test/scala/test/integration/random.scala index 56ad721..027442f 100644 --- a/src/test/scala/test/integration/random.scala +++ b/src/test/scala/test/integration/random.scala @@ -93,4 +93,51 @@ class RandomTests extends FunSuite { check(t.map(_ => Expr.randomUUID()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.h2) } } + + test("randomInt test") { + import scala.language.implicitConversions + + val checkValue = { (rs: ResultSet) => + assert(rs.next()) + val r = rs.getInt(1) + assert(0 <= r && r <= 2) + } + + val checkInclusion = { (rs: ResultSet) => + assert(rs.next()) + val r = rs.getInt(1) + assert(r == 44) + } + + { + import Dialect.postgresql.given + check(t.map(_ => Expr.randomInt(0, 2)).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.postgres) + check(t.map(_ => Expr.randomInt(44, 44)).toQueryIR.toSQLString(), checkInclusion)(withDBNoImplicits.postgres) + } + { + import Dialect.mysql.given + check(t.map(_ => Expr.randomInt(0, 2)).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.mysql) + check(t.map(_ => Expr.randomInt(44, 44)).toQueryIR.toSQLString(), checkInclusion)(withDBNoImplicits.mysql) + } + { + import Dialect.mariadb.given + check(t.map(_ => Expr.randomInt(0, 2)).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.mariadb) + check(t.map(_ => Expr.randomInt(44, 44)).toQueryIR.toSQLString(), checkInclusion)(withDBNoImplicits.mariadb) + } + { + import Dialect.duckdb.given + check(t.map(_ => Expr.randomInt(0, 2)).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.duckdb) + check(t.map(_ => Expr.randomInt(44, 44)).toQueryIR.toSQLString(), checkInclusion)(withDBNoImplicits.duckdb) + } + { + import Dialect.h2.given + check(t.map(_ => Expr.randomInt(0, 2)).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.h2) + check(t.map(_ => Expr.randomInt(44, 44)).toQueryIR.toSQLString(), checkInclusion)(withDBNoImplicits.h2) + } + { + import Dialect.sqlite.given + check(t.map(_ => Expr.randomInt(0, 2)).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.sqlite) + check(t.map(_ => Expr.randomInt(44, 44)).toQueryIR.toSQLString(), checkInclusion)(withDBNoImplicits.sqlite) + } + } } From 2deefbc7288149e3934e657817ad0a01874c52d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 13:11:29 +0100 Subject: [PATCH 026/106] dialect: string lengths --- src/main/scala/tyql/dialects/dialects.scala | 10 +++ src/main/scala/tyql/expr/Expr.scala | 7 +- src/test/scala/test/integration/strings.scala | 80 +++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 src/test/scala/test/integration/strings.scala diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index 519f09a..989a9d0 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -16,6 +16,9 @@ trait Dialect: def quoteStringLiteral(in: String, insideLikePattern: Boolean): String def quoteBooleanLiteral(in: Boolean): String + val stringLengthByCharacters: String = "CHAR_LENGTH" + val stringLengthByBytes: Seq[String] = Seq("OCTET_LENGTH") + object Dialect: val literal_percent = '\uE000' val literal_underscore = '\uE001' @@ -37,6 +40,9 @@ object Dialect: with StringLiteral.PostgresqlBehavior with BooleanLiterals.UseTrueFalse: def name() = "PostgreSQL Dialect" + override val stringLengthByCharacters: String = "length" + + given RandomFloat = new RandomFloat(Some("random")) {} given RandomUUID = new RandomUUID("gen_random_uuid") {} given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"floor(random() * ($b - $a + 1) + $a)::integer") {} @@ -71,6 +77,7 @@ object Dialect: with StringLiteral.AnsiSingleQuote with BooleanLiterals.UseTrueFalse: def name() = "SQLite Dialect" + override val stringLengthByCharacters: String = "length" given RandomFloat = new RandomFloat(None, Some("(0.5 - RANDOM() / CAST(-9223372036854775808 AS REAL) / 2)")) {} given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"cast(abs(random() % ($b - $a + 1) + $a) as integer)") {} // TODO think about how this impacts simplifications and efficient generation @@ -82,6 +89,7 @@ object Dialect: with StringLiteral.AnsiSingleQuote with BooleanLiterals.UseTrueFalse: def name() = "H2 Dialect" + override val stringLengthByCharacters: String = "length" given RandomFloat = new RandomFloat(Some("rand")) {} given RandomUUID = new RandomUUID("RANDOM_UUID") {} @@ -94,6 +102,8 @@ object Dialect: with StringLiteral.DuckdbBehavior with BooleanLiterals.UseTrueFalse: override def name(): String = "DuckDB Dialect" + override val stringLengthByCharacters: String = "length" + override val stringLengthByBytes: Seq[String] = Seq("encode", "octet_length") given RandomFloat = new RandomFloat(Some("random")) {} given RandomUUID = new RandomUUID("uuid") {} diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index baae92c..7b8d9ee 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -81,9 +81,14 @@ object Expr: def ||[S2 <: ExprShape] (y: Expr[Boolean, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = Or(x, y) def unary_! = Not(x) - extension [S1 <: ExprShape](x: Expr[String, S1]) + extension [S1 <: ExprShape](x: Expr[String, S1])(using d: Dialect) def toLowerCase: Expr[String, S1] = Expr.Lower(x) def toUpperCase: Expr[String, S1] = Expr.Upper(x) + def length: Expr[Int, S1] = charLength + def charLength: Expr[Int, S1] = Expr.FunctionCall1[String, Int, S1](d.stringLengthByCharacters, x) + def byteLength: Expr[Int, S1] = d.stringLengthByBytes match + case Seq(f) => Expr.FunctionCall1[String, Int, S1](f, x) + case Seq(inner, outer) => Expr.FunctionCall1[String, Int, S1](outer, Expr.FunctionCall1[String, String, S1](inner, x)) extension [A](x: Expr[List[A], NonScalarExpr])(using ResultTag[List[A]]) def prepend(elem: Expr[A, NonScalarExpr]): Expr[List[A], NonScalarExpr] = ListPrepend(elem, x) diff --git a/src/test/scala/test/integration/strings.scala b/src/test/scala/test/integration/strings.scala new file mode 100644 index 0000000..e3a905d --- /dev/null +++ b/src/test/scala/test/integration/strings.scala @@ -0,0 +1,80 @@ +package test.integration.strings + +import munit.FunSuite +import test.withDBNoImplicits +import java.sql.{Connection, Statement, ResultSet} +import tyql.{Dialect, Table, Expr} +import tyql.Subset.a + +class StringTests extends FunSuite { + def init(stmt: Statement) = { + stmt.executeUpdate(s"DROP TABLE IF EXISTS table1733;") + stmt.executeUpdate(s"CREATE TABLE table1733 (i INTEGER);") + stmt.executeUpdate(s"INSERT INTO table1733 (i) VALUES (1);"); + } + + def clean(stmt: Statement) = { + stmt.executeUpdate(s"DROP TABLE IF EXISTS table1733;") + } + + case class Row(i: Int) + val t = Table[Row]("table1733") + + def check(sqlQuery: String, checkValue: ResultSet => Unit)(runner: (f: Connection => Unit) => Unit): Unit = { + for (_ <- 1 to 3) { + runner { conn => + val stmt = conn.createStatement() + init(stmt) + val rs = stmt.executeQuery(sqlQuery) + checkValue(rs) + clean(stmt) + } + } + } + + test("string length by characters and bytes, length is aliased to characterLength tests") { + import scala.language.implicitConversions + + def checkValue(expected: Int)(rs: ResultSet) = + assert(rs.next()) + val r = rs.getInt(1) + assert(r == expected) + + { + import Dialect.postgresql.given + check(t.map(_ => Expr.StringLit("ałajć").length).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.postgres) + check(t.map(_ => Expr.StringLit("ałajć").charLength).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.postgres) + check(t.map(_ => Expr.StringLit("ałajć").byteLength).toQueryIR.toSQLString(), checkValue(7))(withDBNoImplicits.postgres) + } + { + import Dialect.mysql.given + check(t.map(_ => Expr.StringLit("ałajć").length).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.mysql) + check(t.map(_ => Expr.StringLit("ałajć").charLength).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.mysql) + check(t.map(_ => Expr.StringLit("ałajć").byteLength).toQueryIR.toSQLString(), checkValue(7))(withDBNoImplicits.mysql) + } + { + import Dialect.mariadb.given + check(t.map(_ => Expr.StringLit("ałajć").length).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.mariadb) + check(t.map(_ => Expr.StringLit("ałajć").charLength).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.mariadb) + check(t.map(_ => Expr.StringLit("ałajć").byteLength).toQueryIR.toSQLString(), checkValue(7))(withDBNoImplicits.mariadb) + } + { + import Dialect.duckdb.given + check(t.map(_ => Expr.StringLit("ałajć").length).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.duckdb) + check(t.map(_ => Expr.StringLit("ałajć").charLength).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.duckdb) + check(t.map(_ => Expr.StringLit("ałajć").byteLength).toQueryIR.toSQLString(), checkValue(7))(withDBNoImplicits.duckdb) + } + { + import Dialect.h2.given + check(t.map(_ => Expr.StringLit("ałajć").length).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.h2) + check(t.map(_ => Expr.StringLit("ałajć").charLength).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.h2) + check(t.map(_ => Expr.StringLit("ałajć").byteLength).toQueryIR.toSQLString(), checkValue(7))(withDBNoImplicits.h2) + } + { + import Dialect.sqlite.given + check(t.map(_ => Expr.StringLit("ałajć").length).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.sqlite) + check(t.map(_ => Expr.StringLit("ałajć").charLength).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.sqlite) + check(t.map(_ => Expr.StringLit("ałajć").byteLength).toQueryIR.toSQLString(), checkValue(7))(withDBNoImplicits.sqlite) + } + } +} From 39b0a5fdb807df28cc9000e72a54e3cbb542f4e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 13:36:52 +0100 Subject: [PATCH 027/106] tests: helper method to test single expression --- src/test/scala/test/TestSingeExpr.scala | 26 +++++++ src/test/scala/test/integration/random.scala | 77 ++++++------------- src/test/scala/test/integration/strings.scala | 70 +++++------------ 3 files changed, 70 insertions(+), 103 deletions(-) create mode 100644 src/test/scala/test/TestSingeExpr.scala diff --git a/src/test/scala/test/TestSingeExpr.scala b/src/test/scala/test/TestSingeExpr.scala new file mode 100644 index 0000000..4a7cc86 --- /dev/null +++ b/src/test/scala/test/TestSingeExpr.scala @@ -0,0 +1,26 @@ +package test + +import munit.FunSuite +import test.withDBNoImplicits +import java.sql.{Connection, Statement, ResultSet} +import tyql.{Dialect, Table, Expr} +import tyql.Subset.a +import tyql.{NonScalarExpr, ResultTag} + + +def checkExpr[A](using ResultTag[A]) + (expr: tyql.Expr[A, NonScalarExpr], checkValue: ResultSet => Unit) + (runner: (f: Connection => Unit) => Unit): Unit = { + case class Row(i: Int) + val t = Table[Row]("table59175810544") + runner { conn => + val stmt = conn.createStatement() + stmt.executeUpdate(s"DROP TABLE IF EXISTS table59175810544;") + stmt.executeUpdate(s"CREATE TABLE table59175810544 (i INTEGER);") + stmt.executeUpdate(s"INSERT INTO table59175810544 (i) VALUES (1);") + val rs = stmt.executeQuery(t.map(_ => expr).toQueryIR.toSQLString()) + assert(rs.next()) + checkValue(rs) + stmt.executeUpdate(s"DROP TABLE IF EXISTS table59175810544;") + } +} diff --git a/src/test/scala/test/integration/random.scala b/src/test/scala/test/integration/random.scala index 027442f..4875d82 100644 --- a/src/test/scala/test/integration/random.scala +++ b/src/test/scala/test/integration/random.scala @@ -1,96 +1,69 @@ package test.integration.random import munit.FunSuite -import test.withDBNoImplicits +import test.{withDBNoImplicits, checkExpr} import java.sql.{Connection, Statement, ResultSet} import tyql.{Dialect, Table, Expr} import tyql.Subset.a class RandomTests extends FunSuite { - def init(stmt: Statement) = { - stmt.executeUpdate(s"DROP TABLE IF EXISTS table1733;") - stmt.executeUpdate(s"CREATE TABLE table1733 (i INTEGER);") - stmt.executeUpdate(s"INSERT INTO table1733 (i) VALUES (1);"); - } - - def clean(stmt: Statement) = { - stmt.executeUpdate(s"DROP TABLE IF EXISTS table1733;") - } - - case class Row(i: Int) - val t = Table[Row]("table1733") - - def check(sqlQuery: String, checkValue: ResultSet => Unit)(runner: (f: Connection => Unit) => Unit): Unit = { - for (_ <- 1 to 3) { - runner { conn => - val stmt = conn.createStatement() - init(stmt) - val rs = stmt.executeQuery(sqlQuery) - checkValue(rs) - clean(stmt) - } - } - } - test("randomFloat test") { val checkValue = { (rs: ResultSet) => - assert(rs.next()) val r = rs.getDouble(1) assert(0 <= r && r <= 1) } { import Dialect.postgresql.given - check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.postgres) + checkExpr[Double](Expr.randomFloat(), checkValue)(withDBNoImplicits.postgres) } { import Dialect.mysql.given - check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.mysql) + checkExpr[Double](Expr.randomFloat(), checkValue)(withDBNoImplicits.mysql) } { import Dialect.mariadb.given - check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.mariadb) + checkExpr[Double](Expr.randomFloat(), checkValue)(withDBNoImplicits.mariadb) } { import Dialect.duckdb.given - check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.duckdb) + checkExpr[Double](Expr.randomFloat(), checkValue)(withDBNoImplicits.duckdb) } { import Dialect.h2.given - check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.h2) + checkExpr[Double](Expr.randomFloat(), checkValue)(withDBNoImplicits.h2) } { import Dialect.sqlite.given - check(t.map(_ => Expr.randomFloat()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.sqlite) + checkExpr[Double](Expr.randomFloat(), checkValue)(withDBNoImplicits.sqlite) } } test("randomUUID test") { val checkValue = { (rs: ResultSet) => - assert(rs.next()) val r = rs.getString(1) assert(r.matches("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")) } { import Dialect.postgresql.given - check(t.map(_ => Expr.randomUUID()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.postgres) + checkExpr[String](Expr.randomUUID(), checkValue)(withDBNoImplicits.postgres) } { import Dialect.mysql.given - check(t.map(_ => Expr.randomUUID()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.mysql) + checkExpr[String](Expr.randomUUID(), checkValue)(withDBNoImplicits.mysql) } { import Dialect.mariadb.given - check(t.map(_ => Expr.randomUUID()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.mariadb) + checkExpr[String](Expr.randomUUID(), checkValue)(withDBNoImplicits.mariadb) } { import Dialect.duckdb.given - check(t.map(_ => Expr.randomUUID()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.duckdb) + checkExpr[String](Expr.randomUUID(), checkValue)(withDBNoImplicits.duckdb) } { import Dialect.h2.given - check(t.map(_ => Expr.randomUUID()).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.h2) + checkExpr[String](Expr.randomUUID(), checkValue)(withDBNoImplicits.h2) } } @@ -98,46 +71,44 @@ class RandomTests extends FunSuite { import scala.language.implicitConversions val checkValue = { (rs: ResultSet) => - assert(rs.next()) val r = rs.getInt(1) assert(0 <= r && r <= 2) } val checkInclusion = { (rs: ResultSet) => - assert(rs.next()) val r = rs.getInt(1) assert(r == 44) } { import Dialect.postgresql.given - check(t.map(_ => Expr.randomInt(0, 2)).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.postgres) - check(t.map(_ => Expr.randomInt(44, 44)).toQueryIR.toSQLString(), checkInclusion)(withDBNoImplicits.postgres) + checkExpr[Int](Expr.randomInt(0, 2), checkValue)(withDBNoImplicits.postgres) + checkExpr[Int](Expr.randomInt(44, 44), checkInclusion)(withDBNoImplicits.postgres) } { import Dialect.mysql.given - check(t.map(_ => Expr.randomInt(0, 2)).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.mysql) - check(t.map(_ => Expr.randomInt(44, 44)).toQueryIR.toSQLString(), checkInclusion)(withDBNoImplicits.mysql) + checkExpr[Int](Expr.randomInt(0, 2), checkValue)(withDBNoImplicits.mysql) + checkExpr[Int](Expr.randomInt(44, 44), checkInclusion)(withDBNoImplicits.mysql) } { import Dialect.mariadb.given - check(t.map(_ => Expr.randomInt(0, 2)).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.mariadb) - check(t.map(_ => Expr.randomInt(44, 44)).toQueryIR.toSQLString(), checkInclusion)(withDBNoImplicits.mariadb) + checkExpr[Int](Expr.randomInt(0, 2), checkValue)(withDBNoImplicits.mariadb) + checkExpr[Int](Expr.randomInt(44, 44), checkInclusion)(withDBNoImplicits.mariadb) } { import Dialect.duckdb.given - check(t.map(_ => Expr.randomInt(0, 2)).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.duckdb) - check(t.map(_ => Expr.randomInt(44, 44)).toQueryIR.toSQLString(), checkInclusion)(withDBNoImplicits.duckdb) + checkExpr[Int](Expr.randomInt(0, 2), checkValue)(withDBNoImplicits.duckdb) + checkExpr[Int](Expr.randomInt(44, 44), checkInclusion)(withDBNoImplicits.duckdb) } { import Dialect.h2.given - check(t.map(_ => Expr.randomInt(0, 2)).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.h2) - check(t.map(_ => Expr.randomInt(44, 44)).toQueryIR.toSQLString(), checkInclusion)(withDBNoImplicits.h2) + checkExpr[Int](Expr.randomInt(0, 2), checkValue)(withDBNoImplicits.h2) + checkExpr[Int](Expr.randomInt(44, 44), checkInclusion)(withDBNoImplicits.h2) } { import Dialect.sqlite.given - check(t.map(_ => Expr.randomInt(0, 2)).toQueryIR.toSQLString(), checkValue)(withDBNoImplicits.sqlite) - check(t.map(_ => Expr.randomInt(44, 44)).toQueryIR.toSQLString(), checkInclusion)(withDBNoImplicits.sqlite) + checkExpr[Int](Expr.randomInt(0, 2), checkValue)(withDBNoImplicits.sqlite) + checkExpr[Int](Expr.randomInt(44, 44), checkInclusion)(withDBNoImplicits.sqlite) } } } diff --git a/src/test/scala/test/integration/strings.scala b/src/test/scala/test/integration/strings.scala index e3a905d..fec009f 100644 --- a/src/test/scala/test/integration/strings.scala +++ b/src/test/scala/test/integration/strings.scala @@ -1,80 +1,50 @@ package test.integration.strings import munit.FunSuite -import test.withDBNoImplicits +import test.{withDBNoImplicits, checkExpr} import java.sql.{Connection, Statement, ResultSet} import tyql.{Dialect, Table, Expr} import tyql.Subset.a class StringTests extends FunSuite { - def init(stmt: Statement) = { - stmt.executeUpdate(s"DROP TABLE IF EXISTS table1733;") - stmt.executeUpdate(s"CREATE TABLE table1733 (i INTEGER);") - stmt.executeUpdate(s"INSERT INTO table1733 (i) VALUES (1);"); - } - - def clean(stmt: Statement) = { - stmt.executeUpdate(s"DROP TABLE IF EXISTS table1733;") - } - - case class Row(i: Int) - val t = Table[Row]("table1733") - - def check(sqlQuery: String, checkValue: ResultSet => Unit)(runner: (f: Connection => Unit) => Unit): Unit = { - for (_ <- 1 to 3) { - runner { conn => - val stmt = conn.createStatement() - init(stmt) - val rs = stmt.executeQuery(sqlQuery) - checkValue(rs) - clean(stmt) - } - } - } - test("string length by characters and bytes, length is aliased to characterLength tests") { - import scala.language.implicitConversions - - def checkValue(expected: Int)(rs: ResultSet) = - assert(rs.next()) - val r = rs.getInt(1) - assert(r == expected) + def checkValue(expected: Int)(rs: ResultSet) = assert(rs.getInt(1) == expected) { import Dialect.postgresql.given - check(t.map(_ => Expr.StringLit("ałajć").length).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.postgres) - check(t.map(_ => Expr.StringLit("ałajć").charLength).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.postgres) - check(t.map(_ => Expr.StringLit("ałajć").byteLength).toQueryIR.toSQLString(), checkValue(7))(withDBNoImplicits.postgres) + checkExpr[Int](Expr.StringLit("ałajć").length, checkValue(5))(withDBNoImplicits.postgres) + checkExpr[Int](Expr.StringLit("ałajć").charLength, checkValue(5))(withDBNoImplicits.postgres) + checkExpr[Int](Expr.StringLit("ałajć").byteLength, checkValue(7))(withDBNoImplicits.postgres) } { import Dialect.mysql.given - check(t.map(_ => Expr.StringLit("ałajć").length).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.mysql) - check(t.map(_ => Expr.StringLit("ałajć").charLength).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.mysql) - check(t.map(_ => Expr.StringLit("ałajć").byteLength).toQueryIR.toSQLString(), checkValue(7))(withDBNoImplicits.mysql) + checkExpr[Int](Expr.StringLit("ałajć").length, checkValue(5))(withDBNoImplicits.mysql) + checkExpr[Int](Expr.StringLit("ałajć").charLength, checkValue(5))(withDBNoImplicits.mysql) + checkExpr[Int](Expr.StringLit("ałajć").byteLength, checkValue(7))(withDBNoImplicits.mysql) } { import Dialect.mariadb.given - check(t.map(_ => Expr.StringLit("ałajć").length).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.mariadb) - check(t.map(_ => Expr.StringLit("ałajć").charLength).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.mariadb) - check(t.map(_ => Expr.StringLit("ałajć").byteLength).toQueryIR.toSQLString(), checkValue(7))(withDBNoImplicits.mariadb) + checkExpr[Int](Expr.StringLit("ałajć").length, checkValue(5))(withDBNoImplicits.mariadb) + checkExpr[Int](Expr.StringLit("ałajć").charLength, checkValue(5))(withDBNoImplicits.mariadb) + checkExpr[Int](Expr.StringLit("ałajć").byteLength, checkValue(7))(withDBNoImplicits.mariadb) } { import Dialect.duckdb.given - check(t.map(_ => Expr.StringLit("ałajć").length).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.duckdb) - check(t.map(_ => Expr.StringLit("ałajć").charLength).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.duckdb) - check(t.map(_ => Expr.StringLit("ałajć").byteLength).toQueryIR.toSQLString(), checkValue(7))(withDBNoImplicits.duckdb) + checkExpr[Int](Expr.StringLit("ałajć").length, checkValue(5))(withDBNoImplicits.duckdb) + checkExpr[Int](Expr.StringLit("ałajć").charLength, checkValue(5))(withDBNoImplicits.duckdb) + checkExpr[Int](Expr.StringLit("ałajć").byteLength, checkValue(7))(withDBNoImplicits.duckdb) } { import Dialect.h2.given - check(t.map(_ => Expr.StringLit("ałajć").length).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.h2) - check(t.map(_ => Expr.StringLit("ałajć").charLength).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.h2) - check(t.map(_ => Expr.StringLit("ałajć").byteLength).toQueryIR.toSQLString(), checkValue(7))(withDBNoImplicits.h2) + checkExpr[Int](Expr.StringLit("ałajć").length, checkValue(5))(withDBNoImplicits.h2) + checkExpr[Int](Expr.StringLit("ałajć").charLength, checkValue(5))(withDBNoImplicits.h2) + checkExpr[Int](Expr.StringLit("ałajć").byteLength, checkValue(7))(withDBNoImplicits.h2) } { import Dialect.sqlite.given - check(t.map(_ => Expr.StringLit("ałajć").length).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.sqlite) - check(t.map(_ => Expr.StringLit("ałajć").charLength).toQueryIR.toSQLString(), checkValue(5))(withDBNoImplicits.sqlite) - check(t.map(_ => Expr.StringLit("ałajć").byteLength).toQueryIR.toSQLString(), checkValue(7))(withDBNoImplicits.sqlite) + checkExpr[Int](Expr.StringLit("ałajć").length, checkValue(5))(withDBNoImplicits.sqlite) + checkExpr[Int](Expr.StringLit("ałajć").charLength, checkValue(5))(withDBNoImplicits.sqlite) + checkExpr[Int](Expr.StringLit("ałajć").byteLength, checkValue(7))(withDBNoImplicits.sqlite) } } } From 60c4518a4223556dea5dd45258261e5a1e0e9eed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 13:37:55 +0100 Subject: [PATCH 028/106] remove mistaken import --- src/test/scala/test/integration/random.scala | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/scala/test/integration/random.scala b/src/test/scala/test/integration/random.scala index 4875d82..26645d9 100644 --- a/src/test/scala/test/integration/random.scala +++ b/src/test/scala/test/integration/random.scala @@ -4,7 +4,6 @@ import munit.FunSuite import test.{withDBNoImplicits, checkExpr} import java.sql.{Connection, Statement, ResultSet} import tyql.{Dialect, Table, Expr} -import tyql.Subset.a class RandomTests extends FunSuite { test("randomFloat test") { From 8350d14f031d6001139d05d93ae4cc67fcd11c63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 13:51:59 +0100 Subject: [PATCH 029/106] dialect: string upper/lower --- src/main/scala/tyql/expr/Expr.scala | 2 +- src/main/scala/tyql/ir/QueryIRTree.scala | 1 + src/test/scala/test/integration/random.scala | 2 +- src/test/scala/test/integration/strings.scala | 15 ++++++++++++++- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index 7b8d9ee..a7520e9 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -138,7 +138,7 @@ object Expr: case class Or[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Boolean, S1], $y: Expr[Boolean, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] case class Not[S1 <: ExprShape]($x: Expr[Boolean, S1]) extends Expr[Boolean, S1] - case class Upper[S <: ExprShape]($x: Expr[String, S]) extends Expr[String, S] + case class Upper[S <: ExprShape]($x: Expr[String, S]) extends Expr[String, S] // TODO XXX Do we keep this as separate AST nodes or do we just emit a function call here? case class Lower[S <: ExprShape]($x: Expr[String, S]) extends Expr[String, S] case class ListExpr[A]($elements: List[Expr[A, NonScalarExpr]])(using ResultTag[List[A]]) extends Expr[List[A], NonScalarExpr] diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index 2c0ef8c..956b871 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -374,6 +374,7 @@ object QueryIRTree: case l: Expr.StringLit => Literal(d.quoteStringLiteral(l.$value, insideLikePattern=false), l) // TODO fix this for LIKE patterns case l: Expr.BooleanLit => Literal(d.quoteBooleanLiteral(l.$value), l) case l: Expr.Lower[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"LOWER($o)", l) + case l: Expr.Upper[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"UPPER($o)", l) case a: AggregationExpr[?] => generateAggregation(a, symbols) case a: Aggregation[?, ?] => generateQuery(a, symbols).appendFlag(SelectFlags.ExprLevel) case list: Expr.ListExpr[?] => ListTypeExpr(list.$elements.map(generateExpr(_, symbols)), list) diff --git a/src/test/scala/test/integration/random.scala b/src/test/scala/test/integration/random.scala index 26645d9..ca8f998 100644 --- a/src/test/scala/test/integration/random.scala +++ b/src/test/scala/test/integration/random.scala @@ -76,7 +76,7 @@ class RandomTests extends FunSuite { val checkInclusion = { (rs: ResultSet) => val r = rs.getInt(1) - assert(r == 44) + assertEquals(r, 44) } { diff --git a/src/test/scala/test/integration/strings.scala b/src/test/scala/test/integration/strings.scala index fec009f..0a5f865 100644 --- a/src/test/scala/test/integration/strings.scala +++ b/src/test/scala/test/integration/strings.scala @@ -8,7 +8,7 @@ import tyql.Subset.a class StringTests extends FunSuite { test("string length by characters and bytes, length is aliased to characterLength tests") { - def checkValue(expected: Int)(rs: ResultSet) = assert(rs.getInt(1) == expected) + def checkValue(expected: Int)(rs: ResultSet) = assertEquals(rs.getInt(1), expected) { import Dialect.postgresql.given @@ -47,4 +47,17 @@ class StringTests extends FunSuite { checkExpr[Int](Expr.StringLit("ałajć").byteLength, checkValue(7))(withDBNoImplicits.sqlite) } } + + test("upper and lower work also with unicode") { + def checkValue(expected: String)(rs: ResultSet) = assertEquals(rs.getString(1), expected) + + for (r <- Seq(withDBNoImplicits.postgres, withDBNoImplicits.mariadb, withDBNoImplicits.mysql, withDBNoImplicits.h2, withDBNoImplicits.duckdb)) { + checkExpr[String](Expr.StringLit("aŁaJć").toUpperCase, checkValue("AŁAJĆ"))(r.asInstanceOf[(java.sql.Connection => Unit) => Unit]) + checkExpr[String](Expr.StringLit("aŁaJć").toLowerCase, checkValue("ałajć"))(r.asInstanceOf[(java.sql.Connection => Unit) => Unit]) + } + + // SQLite does not support unicode case folding by default unless compiled with ICU support + checkExpr[String](Expr.StringLit("A bRoWn fOX").toUpperCase, checkValue("A BROWN FOX"))(withDBNoImplicits.sqlite) + checkExpr[String](Expr.StringLit("A bRoWn fOX").toLowerCase, checkValue("a brown fox"))(withDBNoImplicits.sqlite) + } } From 7295e8c7c6938e34f19f346882c1383276691ae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 14:33:34 +0100 Subject: [PATCH 030/106] Dialect generation binds as late as possible --- src/main/scala/tyql/expr/Expr.scala | 12 ++--- src/main/scala/tyql/ir/QueryIRTree.scala | 22 +++++--- src/main/scala/tyql/query/DatabaseAST.scala | 2 +- src/test/scala/test/TestSingeExpr.scala | 17 +++++++ src/test/scala/test/integration/strings.scala | 51 ++++--------------- 5 files changed, 48 insertions(+), 56 deletions(-) diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index a7520e9..85b8027 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -81,14 +81,12 @@ object Expr: def ||[S2 <: ExprShape] (y: Expr[Boolean, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = Or(x, y) def unary_! = Not(x) - extension [S1 <: ExprShape](x: Expr[String, S1])(using d: Dialect) + extension [S1 <: ExprShape](x: Expr[String, S1]) def toLowerCase: Expr[String, S1] = Expr.Lower(x) def toUpperCase: Expr[String, S1] = Expr.Upper(x) + def charLength: Expr[Int, S1] = Expr.StringCharLength(x) def length: Expr[Int, S1] = charLength - def charLength: Expr[Int, S1] = Expr.FunctionCall1[String, Int, S1](d.stringLengthByCharacters, x) - def byteLength: Expr[Int, S1] = d.stringLengthByBytes match - case Seq(f) => Expr.FunctionCall1[String, Int, S1](f, x) - case Seq(inner, outer) => Expr.FunctionCall1[String, Int, S1](outer, Expr.FunctionCall1[String, String, S1](inner, x)) + def byteLength: Expr[Int, S1] = Expr.StringByteLength(x) extension [A](x: Expr[List[A], NonScalarExpr])(using ResultTag[List[A]]) def prepend(elem: Expr[A, NonScalarExpr]): Expr[List[A], NonScalarExpr] = ListPrepend(elem, x) @@ -138,8 +136,10 @@ object Expr: case class Or[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Boolean, S1], $y: Expr[Boolean, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] case class Not[S1 <: ExprShape]($x: Expr[Boolean, S1]) extends Expr[Boolean, S1] - case class Upper[S <: ExprShape]($x: Expr[String, S]) extends Expr[String, S] // TODO XXX Do we keep this as separate AST nodes or do we just emit a function call here? + case class Upper[S <: ExprShape]($x: Expr[String, S]) extends Expr[String, S] case class Lower[S <: ExprShape]($x: Expr[String, S]) extends Expr[String, S] + case class StringCharLength[S <: ExprShape]($x: Expr[String, S]) extends Expr[Int, S] + case class StringByteLength[S <: ExprShape]($x: Expr[String, S]) extends Expr[Int, S] case class ListExpr[A]($elements: List[Expr[A, NonScalarExpr]])(using ResultTag[List[A]]) extends Expr[List[A], NonScalarExpr] extension [A, E <: Expr[A, NonScalarExpr]](x: List[E]) diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index 956b871..7d24ceb 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -14,7 +14,7 @@ import NamedTupleDecomposition.* */ object QueryIRTree: - def generateFullQuery(ast: DatabaseAST[?], symbols: SymbolTable): RelationOp = + def generateFullQuery(ast: DatabaseAST[?], symbols: SymbolTable)(using d: Dialect): RelationOp = generateQuery(ast, symbols).appendFlag(SelectFlags.Final) // ignore top-level parens var idCount = 0 @@ -41,7 +41,7 @@ object QueryIRTree: * e.g. table.flatMap(t1 => table2.flatMap(t2 => table3.map(t3 => (k1 = t1, k2 = t2, k3 = t3))) => * SELECT t1 as k1, t2 as k3, t3 as k3 FROM table1, table2, table3 */ - private def collapseFlatMap(sources: Seq[RelationOp], symbols: SymbolTable, body: Any): (Seq[RelationOp], QueryIRNode) = + private def collapseFlatMap(sources: Seq[RelationOp], symbols: SymbolTable, body: Any)(using d: Dialect): (Seq[RelationOp], QueryIRNode) = body match case map: Query.Map[?, ?] => val actualParam = generateActualParam(map.$from, map.$query.$param, symbols) @@ -118,7 +118,7 @@ object QueryIRTree: * @param symbols Symbol table, e.g. list of aliases in scope * @return */ - private def generateQuery(ast: DatabaseAST[?], symbols: SymbolTable): RelationOp = + private def generateQuery(ast: DatabaseAST[?], symbols: SymbolTable)(using d: Dialect): RelationOp = import TreePrettyPrinter.* // println(s"genQuery: ast=$ast") ast match @@ -285,7 +285,7 @@ object QueryIRTree: case _ => throw new Exception(s"Unimplemented Relation-Op AST: $ast") - private def generateActualParam(from: DatabaseAST[?], formalParam: Expr.Ref[?, ?], symbols: SymbolTable): RelationOp = + private def generateActualParam(from: DatabaseAST[?], formalParam: Expr.Ref[?, ?], symbols: SymbolTable)(using d: Dialect): RelationOp = lookupRecursiveRef(generateQuery(from, symbols), formalParam.stringRef()) /** * Generate the actual parameter expression and bind it to the formal parameter in the symbol table, but @@ -298,7 +298,7 @@ object QueryIRTree: /** * Compile the function body. */ - private def finishGeneratingFun(funBody: Any, boundST: SymbolTable): QueryIRNode = + private def finishGeneratingFun(funBody: Any, boundST: SymbolTable)(using d: Dialect): QueryIRNode = funBody match // case r: Expr.Ref[?] if r.stringRef() == fun.$param.stringRef() => SelectAllExpr() // special case identity function case e: Expr[?, ?] => generateExpr(e, boundST) @@ -310,12 +310,12 @@ object QueryIRTree: * Sometimes, want to split this function into separate steps, for the cases where you want to collate multiple * function bodies within a single expression. */ - private def generateFun(fun: Expr.Fun[?, ?, ?], appliedTo: RelationOp, symbols: SymbolTable): QueryIRNode = + private def generateFun(fun: Expr.Fun[?, ?, ?], appliedTo: RelationOp, symbols: SymbolTable)(using d: Dialect): QueryIRNode = val (body, boundSymbols) = partiallyGenerateFun(fun, appliedTo, symbols) finishGeneratingFun(body, boundSymbols) - private def generateProjection(p: Expr.Project[?] | AggregationExpr.AggProject[?], symbols: SymbolTable): QueryIRNode = + private def generateProjection(p: Expr.Project[?] | AggregationExpr.AggProject[?], symbols: SymbolTable)(using d: Dialect): QueryIRNode = val projectAST = p match case e: Expr.Project[?] => e.$a case a: AggregationExpr.AggProject[?] => a.$a @@ -375,6 +375,12 @@ object QueryIRTree: case l: Expr.BooleanLit => Literal(d.quoteBooleanLiteral(l.$value), l) case l: Expr.Lower[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"LOWER($o)", l) case l: Expr.Upper[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"UPPER($o)", l) + case l: Expr.StringCharLength[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"${d.stringLengthByCharacters}($o)", l) + case l: Expr.StringByteLength[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => + (d.stringLengthByBytes match + case Seq(f) => s"$f($o)" + case Seq(inner, outer) => s"$outer($inner($o))"), + l) case a: AggregationExpr[?] => generateAggregation(a, symbols) case a: Aggregation[?, ?] => generateQuery(a, symbols).appendFlag(SelectFlags.ExprLevel) case list: Expr.ListExpr[?] => ListTypeExpr(list.$elements.map(generateExpr(_, symbols)), list) @@ -386,7 +392,7 @@ object QueryIRTree: case p: Expr.IsEmpty[?] => UnaryExprOp(generateQuery(p.$this, symbols).appendFlag(SelectFlags.Final), s => s"NOT EXISTS ($s)", p) case _ => throw new Exception(s"Unimplemented Expr AST: $ast") - private def generateAggregation(ast: AggregationExpr[?], symbols: SymbolTable): QueryIRNode = + private def generateAggregation(ast: AggregationExpr[?], symbols: SymbolTable)(using d: Dialect): QueryIRNode = ast match case s: AggregationExpr.Sum[?] => UnaryExprOp(generateExpr(s.$a, symbols), o => s"SUM($o)", s) case s: AggregationExpr.Avg[?] => UnaryExprOp(generateExpr(s.$a, symbols), o => s"AVG($o)", s) diff --git a/src/main/scala/tyql/query/DatabaseAST.scala b/src/main/scala/tyql/query/DatabaseAST.scala index 2631b59..4e2022a 100644 --- a/src/main/scala/tyql/query/DatabaseAST.scala +++ b/src/main/scala/tyql/query/DatabaseAST.scala @@ -7,5 +7,5 @@ package tyql trait DatabaseAST[Result](using val qTag: ResultTag[Result]): def toSQLString(using d: Dialect)(using cnf: Config): String = toQueryIR.toSQLString() - def toQueryIR: QueryIRNode = + def toQueryIR(using d: Dialect): QueryIRNode = QueryIRTree.generateFullQuery(this, SymbolTable()) diff --git a/src/test/scala/test/TestSingeExpr.scala b/src/test/scala/test/TestSingeExpr.scala index 4a7cc86..e92bbeb 100644 --- a/src/test/scala/test/TestSingeExpr.scala +++ b/src/test/scala/test/TestSingeExpr.scala @@ -24,3 +24,20 @@ def checkExpr[A](using ResultTag[A]) stmt.executeUpdate(s"DROP TABLE IF EXISTS table59175810544;") } } + +def checkExprDialect[A](using ResultTag[A]) + (expr: tyql.Expr[A, NonScalarExpr], checkValue: ResultSet => Unit) + (runner: (f: Connection => Dialect ?=> Unit) => Unit): Unit = { + case class Row(i: Int) + val t = Table[Row]("table59175810544") + runner { conn => + val stmt = conn.createStatement() + stmt.executeUpdate(s"DROP TABLE IF EXISTS table59175810544;") + stmt.executeUpdate(s"CREATE TABLE table59175810544 (i INTEGER);") + stmt.executeUpdate(s"INSERT INTO table59175810544 (i) VALUES (1);") + val rs = stmt.executeQuery(t.map(_ => expr).toQueryIR.toSQLString()) + assert(rs.next()) + checkValue(rs) + stmt.executeUpdate(s"DROP TABLE IF EXISTS table59175810544;") + } +} diff --git a/src/test/scala/test/integration/strings.scala b/src/test/scala/test/integration/strings.scala index 0a5f865..75a5d9f 100644 --- a/src/test/scala/test/integration/strings.scala +++ b/src/test/scala/test/integration/strings.scala @@ -1,59 +1,28 @@ package test.integration.strings import munit.FunSuite -import test.{withDBNoImplicits, checkExpr} +import test.{withDBNoImplicits, withDB, checkExpr, checkExprDialect} import java.sql.{Connection, Statement, ResultSet} import tyql.{Dialect, Table, Expr} -import tyql.Subset.a class StringTests extends FunSuite { test("string length by characters and bytes, length is aliased to characterLength tests") { def checkValue(expected: Int)(rs: ResultSet) = assertEquals(rs.getInt(1), expected) - { - import Dialect.postgresql.given - checkExpr[Int](Expr.StringLit("ałajć").length, checkValue(5))(withDBNoImplicits.postgres) - checkExpr[Int](Expr.StringLit("ałajć").charLength, checkValue(5))(withDBNoImplicits.postgres) - checkExpr[Int](Expr.StringLit("ałajć").byteLength, checkValue(7))(withDBNoImplicits.postgres) - } - { - import Dialect.mysql.given - checkExpr[Int](Expr.StringLit("ałajć").length, checkValue(5))(withDBNoImplicits.mysql) - checkExpr[Int](Expr.StringLit("ałajć").charLength, checkValue(5))(withDBNoImplicits.mysql) - checkExpr[Int](Expr.StringLit("ałajć").byteLength, checkValue(7))(withDBNoImplicits.mysql) - } - { - import Dialect.mariadb.given - checkExpr[Int](Expr.StringLit("ałajć").length, checkValue(5))(withDBNoImplicits.mariadb) - checkExpr[Int](Expr.StringLit("ałajć").charLength, checkValue(5))(withDBNoImplicits.mariadb) - checkExpr[Int](Expr.StringLit("ałajć").byteLength, checkValue(7))(withDBNoImplicits.mariadb) - } - { - import Dialect.duckdb.given - checkExpr[Int](Expr.StringLit("ałajć").length, checkValue(5))(withDBNoImplicits.duckdb) - checkExpr[Int](Expr.StringLit("ałajć").charLength, checkValue(5))(withDBNoImplicits.duckdb) - checkExpr[Int](Expr.StringLit("ałajć").byteLength, checkValue(7))(withDBNoImplicits.duckdb) - } - { - import Dialect.h2.given - checkExpr[Int](Expr.StringLit("ałajć").length, checkValue(5))(withDBNoImplicits.h2) - checkExpr[Int](Expr.StringLit("ałajć").charLength, checkValue(5))(withDBNoImplicits.h2) - checkExpr[Int](Expr.StringLit("ałajć").byteLength, checkValue(7))(withDBNoImplicits.h2) - } - { - import Dialect.sqlite.given - checkExpr[Int](Expr.StringLit("ałajć").length, checkValue(5))(withDBNoImplicits.sqlite) - checkExpr[Int](Expr.StringLit("ałajć").charLength, checkValue(5))(withDBNoImplicits.sqlite) - checkExpr[Int](Expr.StringLit("ałajć").byteLength, checkValue(7))(withDBNoImplicits.sqlite) - } + // XXX the expression is defined under ANSI SQL dialect, but toSQLQuery is run against a specific dialect and it works! + assertEquals(summon[Dialect].name(), "ANSI SQL Dialect") + checkExprDialect[Int](Expr.StringLit("ałajć").length, checkValue(5))(withDB.all) + checkExprDialect[Int](Expr.StringLit("ałajć").charLength, checkValue(5))(withDB.all) + checkExprDialect[Int](Expr.StringLit("ałajć").byteLength, checkValue(7))(withDB.all) } test("upper and lower work also with unicode") { def checkValue(expected: String)(rs: ResultSet) = assertEquals(rs.getString(1), expected) - for (r <- Seq(withDBNoImplicits.postgres, withDBNoImplicits.mariadb, withDBNoImplicits.mysql, withDBNoImplicits.h2, withDBNoImplicits.duckdb)) { - checkExpr[String](Expr.StringLit("aŁaJć").toUpperCase, checkValue("AŁAJĆ"))(r.asInstanceOf[(java.sql.Connection => Unit) => Unit]) - checkExpr[String](Expr.StringLit("aŁaJć").toLowerCase, checkValue("ałajć"))(r.asInstanceOf[(java.sql.Connection => Unit) => Unit]) + for (r <- Seq(withDBNoImplicits.postgres[Unit], withDBNoImplicits.mariadb[Unit], + withDBNoImplicits.mysql[Unit], withDBNoImplicits.h2[Unit], withDBNoImplicits.duckdb[Unit])) { + checkExpr[String](Expr.StringLit("aŁaJć").toUpperCase, checkValue("AŁAJĆ"))(r) + checkExpr[String](Expr.StringLit("aŁaJć").toLowerCase, checkValue("ałajć"))(r) } // SQLite does not support unicode case folding by default unless compiled with ICU support From b184e46e64ed17d3900e3000bed0b495835a91b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 14:35:19 +0100 Subject: [PATCH 031/106] comment --- src/main/scala/tyql/dialects/dialects.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index 989a9d0..98a2635 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -17,7 +17,7 @@ trait Dialect: def quoteBooleanLiteral(in: Boolean): String val stringLengthByCharacters: String = "CHAR_LENGTH" - val stringLengthByBytes: Seq[String] = Seq("OCTET_LENGTH") + val stringLengthByBytes: Seq[String] = Seq("OCTET_LENGTH") // series of functions to nest, in order from inner to outer object Dialect: val literal_percent = '\uE000' From baea56f10aceb9d5125cc87a3a25648c1f0ebfde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 15:25:22 +0100 Subject: [PATCH 032/106] implement XOR and test boolean operators, we have terrible precedence problems --- src/main/scala/tyql/dialects/dialects.scala | 11 +++-- src/main/scala/tyql/expr/Expr.scala | 2 + src/main/scala/tyql/ir/QueryIRTree.scala | 7 +++ src/test/scala/test/TestSingeExpr.scala | 1 + .../scala/test/integration/booleans.scala | 49 +++++++++++++++++++ 5 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 src/test/scala/test/integration/booleans.scala diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index 98a2635..9b15e50 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -19,6 +19,8 @@ trait Dialect: val stringLengthByCharacters: String = "CHAR_LENGTH" val stringLengthByBytes: Seq[String] = Seq("OCTET_LENGTH") // series of functions to nest, in order from inner to outer + val xorOperatorSupportedNatively = false + object Dialect: val literal_percent = '\uE000' val literal_underscore = '\uE001' @@ -55,6 +57,7 @@ object Dialect: with StringLiteral.MysqlBehavior with BooleanLiterals.UseTrueFalse: def name() = "MySQL Dialect" + override val xorOperatorSupportedNatively = true given RandomFloat = new RandomFloat(Some("rand")) {} given RandomUUID = new RandomUUID("UUID") {} @@ -77,7 +80,7 @@ object Dialect: with StringLiteral.AnsiSingleQuote with BooleanLiterals.UseTrueFalse: def name() = "SQLite Dialect" - override val stringLengthByCharacters: String = "length" + override val stringLengthByCharacters = "length" given RandomFloat = new RandomFloat(None, Some("(0.5 - RANDOM() / CAST(-9223372036854775808 AS REAL) / 2)")) {} given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"cast(abs(random() % ($b - $a + 1) + $a) as integer)") {} // TODO think about how this impacts simplifications and efficient generation @@ -89,7 +92,7 @@ object Dialect: with StringLiteral.AnsiSingleQuote with BooleanLiterals.UseTrueFalse: def name() = "H2 Dialect" - override val stringLengthByCharacters: String = "length" + override val stringLengthByCharacters = "length" given RandomFloat = new RandomFloat(Some("rand")) {} given RandomUUID = new RandomUUID("RANDOM_UUID") {} @@ -102,8 +105,8 @@ object Dialect: with StringLiteral.DuckdbBehavior with BooleanLiterals.UseTrueFalse: override def name(): String = "DuckDB Dialect" - override val stringLengthByCharacters: String = "length" - override val stringLengthByBytes: Seq[String] = Seq("encode", "octet_length") + override val stringLengthByCharacters = "length" + override val stringLengthByBytes = Seq("encode", "octet_length") given RandomFloat = new RandomFloat(Some("random")) {} given RandomUUID = new RandomUUID("uuid") {} diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index 85b8027..7a165a9 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -80,6 +80,7 @@ object Expr: def &&[S2 <: ExprShape] (y: Expr[Boolean, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = And(x, y) def ||[S2 <: ExprShape] (y: Expr[Boolean, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = Or(x, y) def unary_! = Not(x) + def ^(y: Expr[Boolean, S1]): Expr[Boolean, S1] = Xor(x, y) extension [S1 <: ExprShape](x: Expr[String, S1]) def toLowerCase: Expr[String, S1] = Expr.Lower(x) @@ -135,6 +136,7 @@ object Expr: case class And[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Boolean, S1], $y: Expr[Boolean, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] case class Or[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Boolean, S1], $y: Expr[Boolean, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] case class Not[S1 <: ExprShape]($x: Expr[Boolean, S1]) extends Expr[Boolean, S1] + case class Xor[S1 <: ExprShape]($x : Expr[Boolean, S1], $y : Expr[Boolean, S1]) extends Expr[Boolean, S1] case class Upper[S <: ExprShape]($x: Expr[String, S]) extends Expr[String, S] case class Lower[S <: ExprShape]($x: Expr[String, S]) extends Expr[String, S] diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index 7d24ceb..0008317 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -331,6 +331,7 @@ object QueryIRTree: ProjectClause(children, p) private def generateExpr(ast: Expr[?, ?], symbols: SymbolTable)(using d: Dialect): QueryIRNode = + // TODO what about the ()s everywhere? Should we carry precedence information? ast match case ref: Expr.Ref[?, ?] => val name = ref.stringRef() @@ -344,7 +345,13 @@ object QueryIRTree: case g: Expr.GtDouble[?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l > $r", g) case g: Expr.LtDouble[?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l < $r", g) case a: Expr.And[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l AND $r", a) + case a: Expr.Or[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l OR $r", a) case n: Expr.Not[?] => UnaryExprOp(generateExpr(n.$x, symbols), o => s"NOT $o", n) + case x: Expr.Xor[?] => BinExprOp(generateExpr(x.$x, symbols), generateExpr(x.$y, symbols), ((l, r) => + d.xorOperatorSupportedNatively match + case true => s"$l XOR $r" + case false => s"($l = TRUE) <> ($r = TRUE)" + ), x) case f0: Expr.FunctionCall0[?] => FunctionCallOp(f0.name, Seq(), f0) case f1: Expr.FunctionCall1[?, ?, ?] => FunctionCallOp(f1.name, Seq(generateExpr(f1.$a1, symbols)), f1) case f2: Expr.FunctionCall2[?, ?, ?, ?, ?] => FunctionCallOp(f2.name, Seq(f2.$a1, f2.$a1).map(generateExpr(_, symbols)), f2) diff --git a/src/test/scala/test/TestSingeExpr.scala b/src/test/scala/test/TestSingeExpr.scala index e92bbeb..40a9eb3 100644 --- a/src/test/scala/test/TestSingeExpr.scala +++ b/src/test/scala/test/TestSingeExpr.scala @@ -35,6 +35,7 @@ def checkExprDialect[A](using ResultTag[A]) stmt.executeUpdate(s"DROP TABLE IF EXISTS table59175810544;") stmt.executeUpdate(s"CREATE TABLE table59175810544 (i INTEGER);") stmt.executeUpdate(s"INSERT INTO table59175810544 (i) VALUES (1);") + println("Dialect = " + summon[Dialect].name() + " and the query is " + t.map(_ => expr).toQueryIR.toSQLString()) val rs = stmt.executeQuery(t.map(_ => expr).toQueryIR.toSQLString()) assert(rs.next()) checkValue(rs) diff --git a/src/test/scala/test/integration/booleans.scala b/src/test/scala/test/integration/booleans.scala new file mode 100644 index 0000000..07fd8cd --- /dev/null +++ b/src/test/scala/test/integration/booleans.scala @@ -0,0 +1,49 @@ +package test.integration.booleans + +import munit.FunSuite +import test.{withDBNoImplicits, withDB, checkExpr, checkExprDialect} +import java.sql.{Connection, Statement, ResultSet} +import tyql.{Dialect, Table, Expr} + +class BooleanTests extends FunSuite { + val t = Expr.BooleanLit(true) + val f = Expr.BooleanLit(false) + def expect(expected: Boolean)(rs: ResultSet) = assertEquals(rs.getBoolean(1), expected) + + test("boolean encoding") { + checkExprDialect[Boolean](t, expect(true))(withDB.all) + checkExprDialect[Boolean](f, expect(false))(withDB.all) + } + + test("OR table") { + checkExprDialect[Boolean](f || f, expect(false))(withDB.all) + checkExprDialect[Boolean](f || t, expect(true))(withDB.all) + checkExprDialect[Boolean](t || f, expect(true))(withDB.all) + checkExprDialect[Boolean](t || t, expect(true))(withDB.all) + } + + test("AND table") { + checkExprDialect[Boolean](f && f, expect(false))(withDB.all) + checkExprDialect[Boolean](f && t, expect(false))(withDB.all) + checkExprDialect[Boolean](t && f, expect(false))(withDB.all) + checkExprDialect[Boolean](t && t, expect(true))(withDB.all) + } + + test("NOT table") { + checkExprDialect[Boolean](!f, expect(true))(withDB.all) + checkExprDialect[Boolean](!t, expect(false))(withDB.all) + } + + test("XOR table") { + checkExprDialect[Boolean](f ^ f, expect(false))(withDB.all) + checkExprDialect[Boolean](f ^ t, expect(true))(withDB.all) + checkExprDialect[Boolean](t ^ f, expect(true))(withDB.all) + checkExprDialect[Boolean](t ^ t, expect(false))(withDB.all) + } + + // TODO currently very broken! + test("precedence".ignore) { + checkExprDialect[Boolean](t || (f && f), expect(true))(withDB.all) + checkExprDialect[Boolean]((t || f) && f, expect(false))(withDB.all) + } +} From 3db7298e24f5ca4224ccba444a62786785d04f0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 15:42:28 +0100 Subject: [PATCH 033/106] a comment --- src/main/scala/tyql/expr/Expr.scala | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index 7a165a9..df53ec2 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -210,6 +210,9 @@ object Expr: // given Conversion[Boolean, BooleanLit] = BooleanLit(_) // TODO why does this break things? + // TODO this precents an interesting choice, using the exact function names hare means that at the + // time of writing the expression, the dialect needs to be selected, despite the fact that + // this feature is implemented across most dialects. def randomFloat(using r: DialectFeature.RandomFloat)(): Expr[Double, NonScalarExpr] = if r.rawSQL.isDefined then RawSQLInsert[Double](r.rawSQL.get) From 6c7a245662b02365fdc8a49a90991a4dd173f1f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 15:42:47 +0100 Subject: [PATCH 034/106] a comment --- src/main/scala/tyql/expr/Expr.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index df53ec2..ae4f505 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -210,7 +210,7 @@ object Expr: // given Conversion[Boolean, BooleanLit] = BooleanLit(_) // TODO why does this break things? - // TODO this precents an interesting choice, using the exact function names hare means that at the + // TODO this presents an interesting choice, using the exact function names hare means that at the // time of writing the expression, the dialect needs to be selected, despite the fact that // this feature is implemented across most dialects. def randomFloat(using r: DialectFeature.RandomFloat)(): Expr[Double, NonScalarExpr] = From fced269531a854d3fee526ee1433b8212e5b0dd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 15:59:30 +0100 Subject: [PATCH 035/106] a comment --- src/main/scala/tyql/expr/Expr.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index ae4f505..f0d7af1 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -154,7 +154,7 @@ object Expr: case class ListLength[A]($list: Expr[List[A], NonScalarExpr])(using ResultTag[Int]) extends Expr[Int, NonScalarExpr] // So far Select is weakly typed, so `selectDynamic` is easy to implement. - // Todo: Make it strongly typed like the other cases + // TODO: Make it strongly typed like the other cases case class Select[A: ResultTag]($x: Expr[A, ?], $name: String) extends Expr[A, NonScalarExpr] // case class Single[S <: String, A]($x: Expr[A])(using ResultTag[NamedTuple[S *: EmptyTuple, A *: EmptyTuple]]) extends Expr[NamedTuple[S *: EmptyTuple, A *: EmptyTuple]] @@ -226,7 +226,7 @@ object Expr: def randomInt(a: Expr[Int, ?], b: Expr[Int, ?])(using r: DialectFeature.RandomIntegerInInclusiveRange): Expr[Int, NonScalarExpr] = // TODO maybe add a check for (a <= b) if we know both components at generation time? - // TODO what about parenehteses? Do we really not need them? + // TODO what about parentheses? Do we really not need them? val aStr = "A82139520369" val bStr = "B27604933360" RawSQLInsert[Int](r.expr(aStr, bStr), Map(aStr -> a, bStr -> b)) From 26226a009a6b9765cdc01e60c29359f52530216e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 16:15:42 +0100 Subject: [PATCH 036/106] remove debug println --- src/test/scala/test/TestSingeExpr.scala | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/scala/test/TestSingeExpr.scala b/src/test/scala/test/TestSingeExpr.scala index 40a9eb3..e92bbeb 100644 --- a/src/test/scala/test/TestSingeExpr.scala +++ b/src/test/scala/test/TestSingeExpr.scala @@ -35,7 +35,6 @@ def checkExprDialect[A](using ResultTag[A]) stmt.executeUpdate(s"DROP TABLE IF EXISTS table59175810544;") stmt.executeUpdate(s"CREATE TABLE table59175810544 (i INTEGER);") stmt.executeUpdate(s"INSERT INTO table59175810544 (i) VALUES (1);") - println("Dialect = " + summon[Dialect].name() + " and the query is " + t.map(_ => expr).toQueryIR.toSQLString()) val rs = stmt.executeQuery(t.map(_ => expr).toQueryIR.toSQLString()) assert(rs.next()) checkValue(rs) From fc311b0e00bae8bbf35abfff3148a2bcb111197e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 16:27:50 +0100 Subject: [PATCH 037/106] handle precedence to omit unnecessary ()s --- src/main/scala/tyql/expr/Expr.scala | 2 ++ src/main/scala/tyql/ir/QueryIRNode.scala | 35 +++++++++++++++++-- src/main/scala/tyql/ir/QueryIRTree.scala | 35 ++++++++++--------- .../scala/test/integration/booleans.scala | 3 +- 4 files changed, 53 insertions(+), 22 deletions(-) diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index f0d7af1..3762aea 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -58,6 +58,7 @@ object Expr: def <(y: Expr[Int, NonScalarExpr]): Expr[Boolean, CalculatedShape[S1, NonScalarExpr]] = Lt(x, y) def <(y: Int): Expr[Boolean, S1] = Lt[S1, NonScalarExpr](x, IntLit(y)) def <=[S2 <: ExprShape] (y: Expr[Int, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = Lte(x, y) + def >=[S2 <: ExprShape] (y: Expr[Int, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = Gte(x, y) def +[S2 <: ExprShape](y: Expr[Int, S2]): Expr[Int, CalculatedShape[S1, S2]] = Plus(x, y) def +(y: Int): Expr[Int, S1] = Plus[S1, NonScalarExpr, Int](x, IntLit(y)) @@ -121,6 +122,7 @@ object Expr: // Some sample constructors for Exprs case class Lt[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Int, S1], $y: Expr[Int, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] case class Lte[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Int, S1], $y: Expr[Int, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] + case class Gte[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Int, S1], $y: Expr[Int, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] case class Gt[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Int, S1], $y: Expr[Int, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] case class GtDouble[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Double, S1], $y: Expr[Double, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] case class LtDouble[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Double, S1], $y: Expr[Double, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] diff --git a/src/main/scala/tyql/ir/QueryIRNode.scala b/src/main/scala/tyql/ir/QueryIRNode.scala index 071c8d7..8c0b771 100644 --- a/src/main/scala/tyql/ir/QueryIRNode.scala +++ b/src/main/scala/tyql/ir/QueryIRNode.scala @@ -8,8 +8,25 @@ trait QueryIRNode: def toSQLString(using d: Dialect)(using cnf: Config)(): String + val precedence: Int = Precedence.Default + trait QueryIRLeaf extends QueryIRNode +// TODO can we source it from somewhere and not guess about this? +// Current values were proposed by Claude Sonnet 3.5 +object Precedence { + val Literal = 100 // literals, identifiers + val ListOps = 95 // list_append, list_prepend, list_contains + val Unary = 90 // NOT, EXIST, etc + val Multiplicative = 80 // *, / + val Additive = 70 // +, - + val Comparison = 60 // =, <>, <, >, <=, >= + val And = 50 // AND + val Or = 40 // OR + val Concat = 10 // , in select clause + val Default = 0 +} + /** * Single WHERE clause containing 1+ predicates */ @@ -20,21 +37,31 @@ case class WhereClause(children: Seq[QueryIRNode], ast: Expr[?, ?]) extends Quer * Binary expression-level operation. * TODO: cannot assume the operation is universal, need to specialize for DB backend */ -case class BinExprOp(lhs: QueryIRNode, rhs: QueryIRNode, op: (String, String) => String, ast: Expr[?, ?]) extends QueryIRNode: - override def toSQLString(using d: Dialect)(using cnf: Config)(): String = op(lhs.toSQLString(), rhs.toSQLString()) +case class BinExprOp(lhs: QueryIRNode, rhs: QueryIRNode, op: (String, String) => String, override val precedence: Int, ast: Expr[?, ?]) extends QueryIRNode: + override def toSQLString(using d: Dialect)(using cnf: Config)(): String = + val leftStr = if needsParens(lhs) then s"(${lhs.toSQLString()})" else lhs.toSQLString() + val rightStr = if needsParens(rhs) then s"(${rhs.toSQLString()})" else rhs.toSQLString() + op(leftStr, rightStr) + + private def needsParens(node: QueryIRNode): Boolean = + // TODO should this be specialized into needsLeftParen and needsRightParen? + // Unclear if it would be used since we generate from Scala expressions... + node.precedence != 0 && node.precedence < this.precedence /** * Unary expression-level operation. * TODO: cannot assume the operation is universal, need to specialize for DB backend */ case class UnaryExprOp(child: QueryIRNode, op: String => String, ast: Expr[?, ?]) extends QueryIRNode: + override val precedence: Int = Precedence.Unary override def toSQLString(using d: Dialect)(using cnf: Config)(): String = op(s"${child.toSQLString()}") case class FunctionCallOp(name: String, children: Seq[QueryIRNode], ast: Expr[?, ?]) extends QueryIRNode: + override val precedence = Precedence.Literal override def toSQLString(using d: Dialect)(using cnf: Config)(): String = s"$name(" + children.map(_.toSQLString()).mkString(", ") + ")" // TODO does this need ()s sometimes? -case class RawSQLInsertOp(sql: String, replacements: Map[String, QueryIRNode], ast: Expr[?, ?]) extends QueryIRNode: +case class RawSQLInsertOp(sql: String, replacements: Map[String, QueryIRNode], override val precedence: Int, ast: Expr[?, ?]) extends QueryIRNode: override def toSQLString(using d: Dialect)(using cnf: Config)(): String = replacements.foldLeft(sql) { case (acc, (k, v)) => acc.replace(k, v.toSQLString()) } @@ -79,9 +106,11 @@ case class QueryIRVar(toSub: RelationOp, name: String, ast: Expr.Ref[?, ?]) exte * TODO: can't assume stringRep is universal, need to specialize for DB backend. */ case class Literal(stringRep: String, ast: Expr[?, ?]) extends QueryIRLeaf: + override val precedence: Int = Precedence.Literal override def toSQLString(using d: Dialect)(using cnf: Config)(): String = stringRep case class ListTypeExpr(elements: List[QueryIRNode], ast: Expr[?, ?]) extends QueryIRNode: + override val precedence: Int = Precedence.Literal override def toSQLString(using d: Dialect)(using cnf: Config)(): String = elements.map(_.toSQLString()).mkString("[", ", ", "]") /** diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index 0008317..636bf26 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -331,7 +331,6 @@ object QueryIRTree: ProjectClause(children, p) private def generateExpr(ast: Expr[?, ?], symbols: SymbolTable)(using d: Dialect): QueryIRNode = - // TODO what about the ()s everywhere? Should we carry precedence information? ast match case ref: Expr.Ref[?, ?] => val name = ref.stringRef() @@ -339,27 +338,28 @@ object QueryIRTree: QueryIRVar(sub, name, ref) // TODO: singleton? case s: Expr.Select[?] => SelectExpr(s.$name, generateExpr(s.$x, symbols), s) case p: Expr.Project[?] => generateProjection(p, symbols) - case g: Expr.Gt[?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l > $r", g) - case g: Expr.Lt[?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l < $r", g) - case g: Expr.Lte[?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l <= $r", g) - case g: Expr.GtDouble[?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l > $r", g) - case g: Expr.LtDouble[?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l < $r", g) - case a: Expr.And[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l AND $r", a) - case a: Expr.Or[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l OR $r", a) + case g: Expr.Gt[?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l > $r", Precedence.Comparison, g) + case g: Expr.Lt[?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l < $r", Precedence.Comparison, g) + case g: Expr.Lte[?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l <= $r", Precedence.Comparison, g) + case g: Expr.Gte[?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l <= $r", Precedence.Comparison, g) + case g: Expr.GtDouble[?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l > $r", Precedence.Comparison, g) + case g: Expr.LtDouble[?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l < $r", Precedence.Comparison, g) + case a: Expr.And[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l AND $r", Precedence.And, a) + case a: Expr.Or[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l OR $r", Precedence.Or, a) case n: Expr.Not[?] => UnaryExprOp(generateExpr(n.$x, symbols), o => s"NOT $o", n) case x: Expr.Xor[?] => BinExprOp(generateExpr(x.$x, symbols), generateExpr(x.$y, symbols), ((l, r) => d.xorOperatorSupportedNatively match case true => s"$l XOR $r" case false => s"($l = TRUE) <> ($r = TRUE)" - ), x) + ), if d.xorOperatorSupportedNatively then 45 else 43, x) // TODO precedence? case f0: Expr.FunctionCall0[?] => FunctionCallOp(f0.name, Seq(), f0) case f1: Expr.FunctionCall1[?, ?, ?] => FunctionCallOp(f1.name, Seq(generateExpr(f1.$a1, symbols)), f1) case f2: Expr.FunctionCall2[?, ?, ?, ?, ?] => FunctionCallOp(f2.name, Seq(f2.$a1, f2.$a1).map(generateExpr(_, symbols)), f2) - case r: Expr.RawSQLInsert[?] => RawSQLInsertOp(r.sql, r.replacements.mapValues(generateExpr(_, symbols)).toMap, r) - case a: Expr.Plus[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l + $r", a) - case a: Expr.Times[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l * $r", a) - case a: Expr.Eq[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l = $r", a) - case a: Expr.Ne[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l <> $r", a) + case r: Expr.RawSQLInsert[?] => RawSQLInsertOp(r.sql, r.replacements.mapValues(generateExpr(_, symbols)).toMap, Precedence.Default, r) // TODO precedence? + case a: Expr.Plus[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l + $r", Precedence.Additive, a) + case a: Expr.Times[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l * $r", Precedence.Multiplicative, a) + case a: Expr.Eq[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l = $r", Precedence.Comparison, a) + case a: Expr.Ne[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l <> $r", Precedence.Comparison, a) case a: Expr.Concat[?, ?, ?, ?] => val lhsIR = generateExpr(a.$x, symbols) match case p: ProjectClause => p @@ -374,6 +374,7 @@ object QueryIRTree: lhsIR, rhsIR, (l, r) => s"$l, $r", + Precedence.Concat, a ) case l: Expr.DoubleLit => Literal(s"${l.$value}", l) @@ -391,9 +392,9 @@ object QueryIRTree: case a: AggregationExpr[?] => generateAggregation(a, symbols) case a: Aggregation[?, ?] => generateQuery(a, symbols).appendFlag(SelectFlags.ExprLevel) case list: Expr.ListExpr[?] => ListTypeExpr(list.$elements.map(generateExpr(_, symbols)), list) - case p: Expr.ListPrepend[?] => BinExprOp(generateExpr(p.$x, symbols), generateExpr(p.$list, symbols), (l, r) => s"list_prepend($l, $r)", p) - case p: Expr.ListAppend[?] => BinExprOp(generateExpr(p.$list, symbols), generateExpr(p.$x, symbols),(l, r) => s"list_append($l, $r)", p) - case p: Expr.ListContains[?] => BinExprOp(generateExpr(p.$list, symbols), generateExpr(p.$x, symbols),(l, r) => s"list_contains($l, $r)", p) + case p: Expr.ListPrepend[?] => BinExprOp(generateExpr(p.$x, symbols), generateExpr(p.$list, symbols), (l, r) => s"list_prepend($l, $r)", Precedence.ListOps, p) + case p: Expr.ListAppend[?] => BinExprOp(generateExpr(p.$list, symbols), generateExpr(p.$x, symbols),(l, r) => s"list_append($l, $r)", Precedence.ListOps, p) + case p: Expr.ListContains[?] => BinExprOp(generateExpr(p.$list, symbols), generateExpr(p.$x, symbols),(l, r) => s"list_contains($l, $r)", Precedence.ListOps, p) case p: Expr.ListLength[?] => UnaryExprOp(generateExpr(p.$list, symbols), s => s"length($s)", p) case p: Expr.NonEmpty[?] => UnaryExprOp(generateQuery(p.$this, symbols).appendFlag(SelectFlags.Final), s => s"EXISTS ($s)", p) case p: Expr.IsEmpty[?] => UnaryExprOp(generateQuery(p.$this, symbols).appendFlag(SelectFlags.Final), s => s"NOT EXISTS ($s)", p) diff --git a/src/test/scala/test/integration/booleans.scala b/src/test/scala/test/integration/booleans.scala index 07fd8cd..ad73c17 100644 --- a/src/test/scala/test/integration/booleans.scala +++ b/src/test/scala/test/integration/booleans.scala @@ -41,8 +41,7 @@ class BooleanTests extends FunSuite { checkExprDialect[Boolean](t ^ t, expect(false))(withDB.all) } - // TODO currently very broken! - test("precedence".ignore) { + test("precedence") { checkExprDialect[Boolean](t || (f && f), expect(true))(withDB.all) checkExprDialect[Boolean]((t || f) && f, expect(false))(withDB.all) } From a4d7b4c134fbd4346a212ce58bfb465690ba99ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 16:46:20 +0100 Subject: [PATCH 038/106] credit Claude with information extraction tasks from documentation --- .../tyql/dialects/quoting identifiers.scala | 26 ++++++------------- .../scala/tyql/dialects/string literals.scala | 12 ++++----- src/main/scala/tyql/ir/QueryIRNode.scala | 2 +- 3 files changed, 15 insertions(+), 25 deletions(-) diff --git a/src/main/scala/tyql/dialects/quoting identifiers.scala b/src/main/scala/tyql/dialects/quoting identifiers.scala index a7ad3a8..9f13e7b 100644 --- a/src/main/scala/tyql/dialects/quoting identifiers.scala +++ b/src/main/scala/tyql/dialects/quoting identifiers.scala @@ -26,8 +26,8 @@ object QuotingIdentifiers: trait AnsiBehavior extends DoubleQuotes: - // keywords list is also maintained by PostgreSQL at // https://www.postgresql.org/docs/current/sql-keywords-appendix.html - // last updated 2024-11-15 + // keywords list is also maintained by PostgreSQL at https://www.postgresql.org/docs/current/sql-keywords-appendix.html + // last extracted 2024-11-15 using Claude Sonnet 3.5 v20241022 override protected val reservedKeywords: Set[String] = Set( "ABSOLUTE", "ACTION", "ADD", "ALL", "ALLOCATE", "ALTER", "AND", "ANY", "ARE", "AS", "ASC", "ASSERTION", "AT", "AUTHORIZATION", "AVG", @@ -73,7 +73,7 @@ object QuotingIdentifiers: trait PostgresqlBehavior extends DoubleQuotes: // https://www.postgresql.org/docs/current/sql-keywords-appendix.html - // last updated 2024-11-15 + // last extracted 2024-11-15 using Claude Sonnet 3.5 v20241022 override protected val reservedKeywords: Set[String] = Set( "ANALYSE", "ANALYZE", // Note: both ANALYSE and ANALYZE are reserved in PostgreSQL "ALL", "AND", "ANY", "AS", "ASC", "ASYMMETRIC", "BOTH", "CASE", "CAST", "CHECK", @@ -88,7 +88,7 @@ object QuotingIdentifiers: trait MysqlBehavior extends Backticks: // https://dev.mysql.com/doc/refman/8.0/en/keywords.html - // last updated 2024-11-15 + // last extracted 2024-11-15 using Claude Sonnet 3.5 v20241022 override protected val reservedKeywords: Set[String] = Set( "ACCESSIBLE", "ADD", "ALL", "ALTER", "ANALYZE", "AND", "AS", "ASC", "ASENSITIVE", "BEFORE", "BETWEEN", "BIGINT", "BINARY", "BLOB", "BOTH", "BY", @@ -135,7 +135,7 @@ object QuotingIdentifiers: trait MariadbBehavior extends Backticks: // https://mariadb.com/kb/en/reserved-words/ - // last updated 2024-11-15 + // last extracted 2024-11-15 using Claude Sonnet 3.5 v20241022 override protected val reservedKeywords: Set[String] = Set( "ACCESSIBLE", "ADD", "ALL", "ALTER", "ANALYZE", "AND", "AS", "ASC", "ASENSITIVE", "BEFORE", "BETWEEN", "BIGINT", "BINARY", "BLOB", "BOTH", "BY", @@ -184,7 +184,7 @@ object QuotingIdentifiers: trait SqliteBehavior extends DoubleQuotes: // https://www.sqlite.org/lang_keywords.html - // last updated 2024-11-15 + // last extracted 2024-11-15 using Claude Sonnet 3.5 v20241022 override protected val reservedKeywords: Set[String] = Set( "ABORT", "ACTION", "ADD", "AFTER", "ALL", "ALTER", "ALWAYS", "ANALYZE", "AND", "AS", "ASC", "ATTACH", "AUTOINCREMENT", "BEFORE", "BEGIN", "BETWEEN", "BY", "CASCADE", @@ -208,7 +208,7 @@ object QuotingIdentifiers: trait H2Behavior extends DoubleQuotes: // https://h2database.com/html/grammar.html - // last updated 2024-11-15 + // last extracted 2024-11-15 using Claude Sonnet 3.5 v20241022 override protected val reservedKeywords: Set[String] = Set( // Basic SQL keywords "ALL", "AND", "ANY", "ARRAY", "AS", "ASC", "BETWEEN", "BOTH", "CASE", "CAST", @@ -220,24 +220,14 @@ object QuotingIdentifiers: "OFFSET", "ON", "OR", "ORDER", "PRIMARY", "QUALIFY", "REGEXP", "RIGHT", "ROW", "SELECT", "SYSDATE", "SYSTIME", "SYSTIMESTAMP", "TABLE", "TODAY", "TOP", "TRAILING", "TRUE", "UNION", "UNIQUE", "UNKNOWN", "USING", "VALUES", "WHERE", "WINDOW", "WITH", - - // Data manipulation "DELETE", "INSERT", "MERGE", "REPLACE", "UPDATE", "UPSERT", - - // Data definition "ADD", "ALTER", "COLUMN", "CREATE", "DATABASE", "DROP", "INDEX", "SCHEMA", "SET", "TABLE", "VIEW", - - // Transaction control "COMMIT", "ROLLBACK", "SAVEPOINT", "START", - - // H2-specific "_ROWID_", "AUTOCOMMIT", "CACHED", "CHECKPOINT", "EXCLUSIVE", "IGNORECASE", "IFEXISTS", "IFNOTEXISTS", "MEMORY", "MINUS", "NEXT", "OF", "OFF", "PASSWORD", "READONLY", "REFERENTIAL_INTEGRITY", "REUSE", "ROWNUM", "SEQUENCE", "TEMP", "TEMPORARY", "TRIGGER", "VALUE", "YEAR", - - // Data types "BINARY", "BLOB", "BOOLEAN", "CHAR", "CHARACTER", "CLOB", "DATE", "DECIMAL", "DOUBLE", "FLOAT", "INT", "INTEGER", "LONG", "NUMBER", "NUMERIC", "REAL", "SMALLINT", "TIME", "TIMESTAMP", "TINYINT", "VARCHAR" @@ -245,7 +235,7 @@ object QuotingIdentifiers: trait DuckdbBehavior extends DoubleQuotes: // SELECT keyword_name FROM duckdb_keywords() WHERE keyword_category IN ('reserved', 'type_function') - // last updated 2024-11-15 + // last extracted 2024-11-15 from DuckDB REPL v1.1.3 19864453f7 override protected val reservedKeywords: Set[String] = Set( "ALL", "ANALYSE", "ANALYZE", "AND", "ANY", "ARRAY", "AS", "ASC", "ASYMMETRIC", "BOTH", "CASE", "CAST", "CHECK", "COLLATE", "COLUMN", diff --git a/src/main/scala/tyql/dialects/string literals.scala b/src/main/scala/tyql/dialects/string literals.scala index 6f9341a..cf9c1cb 100644 --- a/src/main/scala/tyql/dialects/string literals.scala +++ b/src/main/scala/tyql/dialects/string literals.scala @@ -15,7 +15,7 @@ object StringLiteral: (in.replace(Dialect.literal_percent, '%').replace(Dialect.literal_underscore, '_'), false) trait AnsiSingleQuote extends Dialect: - // last updated 2024-11-15 + // last updated 2024-11-15 using Claude Sonnet 3.5 v20241022 // https://www.sqlite.org/lang_expr.html // https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-STRINGS override def quoteStringLiteral(lit: String, insideLikePattern: Boolean): String = @@ -24,7 +24,7 @@ object StringLiteral: if shouldAddEscape then s"$out ESCAPE '\\'" else out trait PostgresqlBehavior extends Dialect: - // last updated 2024-11-15 + // last updated 2024-11-15 using Claude Sonnet 3.5 v20241022 // https://www.postgresql.org/docs/current/sql-syntax-lexical.html override def quoteStringLiteral(lit: String, insideLikePattern: Boolean): String = val (in, shouldAddEscape) = handleLiteralPatterns(insideLikePattern, lit) @@ -32,7 +32,7 @@ object StringLiteral: "E'" + in.replace("\\", "\\\\") .replace("'", "\\'") .replace("\b", "\\b") - .replace("\f", "\\f") + .replace("\f", "\\f") .replace("\n", "\\n") .replace("\r", "\\r") .replace("\t", "\\t") + "'" @@ -41,7 +41,7 @@ object StringLiteral: if shouldAddEscape then s"$out ESCAPE '\\'" else out trait MysqlBehavior extends Dialect: - // last updated 2024-11-15 + // last updated 2024-11-15 using Claude Sonnet 3.5 v20241022 // https://dev.mysql.com/doc/refman/8.4/en/string-literals.html // https://mariadb.com/kb/en/string-literals/ // XXX _ and % have different meaning in LIKE strings. For now we never escape them, @@ -51,7 +51,7 @@ object StringLiteral: val out = "'" + in.replace("\\", "\\\\") .replace("\u0000", "\\0") .replace("'", "\\'") - .replace("\"", "\\\"") + .replace("\"", "\\\"") .replace("\b", "\\b") .replace("\n", "\\n") .replace("\r", "\\r") @@ -60,7 +60,7 @@ object StringLiteral: out // ignore `ESCAPE` since MySQL/MariaDB do not support it trait DuckdbBehavior extends Dialect: - // last updated 2024-11-15 + // last updated 2024-11-15 using Claude Sonnet 3.5 v20241022 // https://duckdb.org/docs/sql/data_types/literal_types.html#string-literals override def quoteStringLiteral(lit: String, insideLikePattern: Boolean): String = val (in, shouldAddEscape) = handleLiteralPatterns(insideLikePattern, lit) diff --git a/src/main/scala/tyql/ir/QueryIRNode.scala b/src/main/scala/tyql/ir/QueryIRNode.scala index 8c0b771..b74d6a6 100644 --- a/src/main/scala/tyql/ir/QueryIRNode.scala +++ b/src/main/scala/tyql/ir/QueryIRNode.scala @@ -13,7 +13,7 @@ trait QueryIRNode: trait QueryIRLeaf extends QueryIRNode // TODO can we source it from somewhere and not guess about this? -// Current values were proposed by Claude Sonnet 3.5 +// Current values were proposed on 2024-11-19 by Claude Sonnet 3.5 v20241022, and somewhat modifier after object Precedence { val Literal = 100 // literals, identifiers val ListOps = 95 // list_append, list_prepend, list_contains From 99227a313922277309b3d528131b779b2a1fea5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 16:58:10 +0100 Subject: [PATCH 039/106] Add DataGrip configuration to README.md --- README.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/README.md b/README.md index 835ca66..82b22d4 100644 --- a/README.md +++ b/README.md @@ -45,3 +45,62 @@ The containerized environment includes: - MySQL (port 3307) - MariaDB (port 3308) - SQLite, DuckDB, H2 (in-memory from the main container) + +### DataGrip IDE + +`.idea/dataSources.xml` +```xml + + + + + mysql.8 + true + com.mysql.cj.jdbc.Driver + jdbc:mysql://localhost:3307 + $ProjectFileDir$ + + + h2.unified + true + org.h2.Driver + jdbc:h2:mem:default + $ProjectFileDir$ + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://localhost:5433/testdb + $ProjectFileDir$ + + + mariadb + true + org.mariadb.jdbc.Driver + jdbc:mariadb://localhost:3308 + $ProjectFileDir$ + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite::memory: + $ProjectFileDir$ + + + duckdb + true + org.duckdb.DuckDBDriver + jdbc:duckdb: + $ProjectFileDir$ + + + +``` + +SQLite, H2, DuckDB are in-memory, no auth. Postgres, MySQL, MariaDB are localhost testuser:testpass. Watch out for ports! +- MySQL: 3307 (and not 3306) +- PostgreSQL: 5433 (and not 5432) +- MariaDB: 3308 (and not 3306) + From bac29481d0d5d447bdf2b432d0bcbe73d89c86be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 17:00:58 +0100 Subject: [PATCH 040/106] DataGrip configuration screenshot --- README.md | 3 +++ documentation/correctly-configured-DataGrip.png | Bin 0 -> 65234 bytes 2 files changed, 3 insertions(+) create mode 100644 documentation/correctly-configured-DataGrip.png diff --git a/README.md b/README.md index 82b22d4..5445b07 100644 --- a/README.md +++ b/README.md @@ -104,3 +104,6 @@ SQLite, H2, DuckDB are in-memory, no auth. Postgres, MySQL, MariaDB are localhos - PostgreSQL: 5433 (and not 5432) - MariaDB: 3308 (and not 3306) +This is what a correctly configured DataGrip looks like: +![Correctly Configured DataGrip](documentation/correctly-configured-DataGrip.png) + diff --git a/documentation/correctly-configured-DataGrip.png b/documentation/correctly-configured-DataGrip.png new file mode 100644 index 0000000000000000000000000000000000000000..085a31110de4428010765fadb9abb9e025a4d9c9 GIT binary patch literal 65234 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

qAV=+ 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 literal 0 HcmV?d00001 From f930dd082154594b80f605c320f6e8c2a9bf8823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 17:07:22 +0100 Subject: [PATCH 041/106] a warning comment --- src/main/scala/tyql/ir/QueryIRNode.scala | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/scala/tyql/ir/QueryIRNode.scala b/src/main/scala/tyql/ir/QueryIRNode.scala index b74d6a6..c9116d1 100644 --- a/src/main/scala/tyql/ir/QueryIRNode.scala +++ b/src/main/scala/tyql/ir/QueryIRNode.scala @@ -6,6 +6,7 @@ package tyql trait QueryIRNode: val ast: DatabaseAST[?] | Expr[?, ?] | Expr.Fun[?, ?, ?] // Best-effort, keep AST around for debugging, TODO: probably remove, or replace only with ResultTag + // TODO WARNING if you add memoization here, what append when Dialect or Config changes? def toSQLString(using d: Dialect)(using cnf: Config)(): String val precedence: Int = Precedence.Default From d4b12c22263c2f003fd30398aa09b295d6bbc5fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 18:15:45 +0100 Subject: [PATCH 042/106] cache QueryIRNode.toSQLString --- src/main/scala/tyql/ir/QueryIRNode.scala | 40 ++++++++++++++---------- src/main/scala/tyql/ir/RelationOp.scala | 16 +++++----- 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/src/main/scala/tyql/ir/QueryIRNode.scala b/src/main/scala/tyql/ir/QueryIRNode.scala index c9116d1..c3f9029 100644 --- a/src/main/scala/tyql/ir/QueryIRNode.scala +++ b/src/main/scala/tyql/ir/QueryIRNode.scala @@ -6,15 +6,23 @@ package tyql trait QueryIRNode: val ast: DatabaseAST[?] | Expr[?, ?] | Expr.Fun[?, ?, ?] // Best-effort, keep AST around for debugging, TODO: probably remove, or replace only with ResultTag - // TODO WARNING if you add memoization here, what append when Dialect or Config changes? - def toSQLString(using d: Dialect)(using cnf: Config)(): String - val precedence: Int = Precedence.Default + private var cached: java.util.concurrent.ConcurrentHashMap[(Dialect, Config), String] = null // do not allocate memory if unused + + final def toSQLString(using d: Dialect)(using cnf: Config)(): String = + if cached == null then + this.synchronized { + if cached == null then + cached = new java.util.concurrent.ConcurrentHashMap[(Dialect, Config), String]() + } + cached.computeIfAbsent((d, cnf), _ => computeSQLString(using d)(using cnf)()) + + protected def computeSQLString(using d: Dialect)(using cnf: Config)(): String trait QueryIRLeaf extends QueryIRNode // TODO can we source it from somewhere and not guess about this? -// Current values were proposed on 2024-11-19 by Claude Sonnet 3.5 v20241022, and somewhat modifier after +// Current values were proposed on 2024-11-19 by Claude Sonnet 3.5 v20241022, and somewhat modifier later object Precedence { val Literal = 100 // literals, identifiers val ListOps = 95 // list_append, list_prepend, list_contains @@ -32,14 +40,14 @@ object Precedence { * Single WHERE clause containing 1+ predicates */ case class WhereClause(children: Seq[QueryIRNode], ast: Expr[?, ?]) extends QueryIRNode: - override def toSQLString(using d: Dialect)(using cnf: Config)(): String = if children.size == 1 then children.head.toSQLString() else s"${children.map(_.toSQLString()).mkString("", " AND ", "")}" + override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = if children.size == 1 then children.head.toSQLString() else s"${children.map(_.toSQLString()).mkString("", " AND ", "")}" /** * Binary expression-level operation. * TODO: cannot assume the operation is universal, need to specialize for DB backend */ case class BinExprOp(lhs: QueryIRNode, rhs: QueryIRNode, op: (String, String) => String, override val precedence: Int, ast: Expr[?, ?]) extends QueryIRNode: - override def toSQLString(using d: Dialect)(using cnf: Config)(): String = + override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = val leftStr = if needsParens(lhs) then s"(${lhs.toSQLString()})" else lhs.toSQLString() val rightStr = if needsParens(rhs) then s"(${rhs.toSQLString()})" else rhs.toSQLString() op(leftStr, rightStr) @@ -55,15 +63,15 @@ case class BinExprOp(lhs: QueryIRNode, rhs: QueryIRNode, op: (String, String) => */ case class UnaryExprOp(child: QueryIRNode, op: String => String, ast: Expr[?, ?]) extends QueryIRNode: override val precedence: Int = Precedence.Unary - override def toSQLString(using d: Dialect)(using cnf: Config)(): String = op(s"${child.toSQLString()}") + override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = op(s"${child.toSQLString()}") case class FunctionCallOp(name: String, children: Seq[QueryIRNode], ast: Expr[?, ?]) extends QueryIRNode: override val precedence = Precedence.Literal - override def toSQLString(using d: Dialect)(using cnf: Config)(): String = s"$name(" + children.map(_.toSQLString()).mkString(", ") + ")" + override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = s"$name(" + children.map(_.toSQLString()).mkString(", ") + ")" // TODO does this need ()s sometimes? case class RawSQLInsertOp(sql: String, replacements: Map[String, QueryIRNode], override val precedence: Int, ast: Expr[?, ?]) extends QueryIRNode: - override def toSQLString(using d: Dialect)(using cnf: Config)(): String = + override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = replacements.foldLeft(sql) { case (acc, (k, v)) => acc.replace(k, v.toSQLString()) } /** @@ -72,7 +80,7 @@ case class RawSQLInsertOp(sql: String, replacements: Map[String, QueryIRNode], o * @param ast */ case class ProjectClause(children: Seq[QueryIRNode], ast: Expr[?, ?]) extends QueryIRNode: - override def toSQLString(using d: Dialect)(using cnf: Config)(): String = children.map(_.toSQLString()).mkString("", ", ", "") + override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = children.map(_.toSQLString()).mkString("", ", ", "") /** @@ -84,20 +92,20 @@ case class AttrExpr(child: QueryIRNode, projectedName: Option[String], ast: Expr val asStr = projectedName match case Some(value) => s" as ${d.quoteIdentifier(value)}" case None => "" - override def toSQLString(using d: Dialect)(using cnf: Config)(): String = s"${child.toSQLString()}$asStr" + override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = s"${child.toSQLString()}$asStr" /** * Attribute access expression, e.g. `table.rowName`. */ case class SelectExpr(attrName: String, from: QueryIRNode, ast: Expr[?, ?]) extends QueryIRLeaf: - override def toSQLString(using d: Dialect)(using cnf: Config)(): String = + override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = s"${from.toSQLString()}.${d.quoteIdentifier(cnf.caseConvention.convert(attrName))}" /** * A variable that points to a table or subquery. */ case class QueryIRVar(toSub: RelationOp, name: String, ast: Expr.Ref[?, ?]) extends QueryIRLeaf: - override def toSQLString(using d: Dialect)(using cnf: Config)() = + override def computeSQLString(using d: Dialect)(using cnf: Config)() = d.quoteIdentifier(cnf.caseConvention.convert(toSub.alias)) override def toString: String = s"VAR(${toSub.alias}.${name})" // TODO what about this? @@ -108,14 +116,14 @@ case class QueryIRVar(toSub: RelationOp, name: String, ast: Expr.Ref[?, ?]) exte */ case class Literal(stringRep: String, ast: Expr[?, ?]) extends QueryIRLeaf: override val precedence: Int = Precedence.Literal - override def toSQLString(using d: Dialect)(using cnf: Config)(): String = stringRep + override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = stringRep case class ListTypeExpr(elements: List[QueryIRNode], ast: Expr[?, ?]) extends QueryIRNode: override val precedence: Int = Precedence.Literal - override def toSQLString(using d: Dialect)(using cnf: Config)(): String = elements.map(_.toSQLString()).mkString("[", ", ", "]") + override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = elements.map(_.toSQLString()).mkString("[", ", ", "]") /** * Empty leaf node, to avoid Options everywhere. */ case class EmptyLeaf(ast: DatabaseAST[?] = null) extends QueryIRLeaf: - override def toSQLString(using d: Dialect)(using cnf: Config)(): String = "" + override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = "" diff --git a/src/main/scala/tyql/ir/RelationOp.scala b/src/main/scala/tyql/ir/RelationOp.scala index c80aa30..e76ffec 100644 --- a/src/main/scala/tyql/ir/RelationOp.scala +++ b/src/main/scala/tyql/ir/RelationOp.scala @@ -71,7 +71,7 @@ case class TableLeaf(tableName: String, ast: Table[?]) extends RelationOp with Q val name = s"$tableName${QueryIRTree.idCount}" QueryIRTree.idCount += 1 override def alias = name - override def toSQLString(using d: Dialect)(using cnf: Config)(): String = + override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = val escapedTableName = d.quoteIdentifier(cnf.caseConvention.convert(tableName)) val escapedAlias = d.quoteIdentifier(cnf.caseConvention.convert(name)) // TODO does it break something? Think about it if (flags.contains(SelectFlags.Final)) @@ -160,7 +160,7 @@ case class SelectAllQuery(from: Seq[RelationOp], flags = flags + f this - override def toSQLString(using d: Dialect)(using cnf: Config)(): String = + override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = val flagsStr = if flags.contains(SelectFlags.Distinct) then "DISTINCT " else "" val fromStr = from.map(f => f.toSQLString()).mkString("", ", ", "") val whereStr = if where.nonEmpty then @@ -216,7 +216,7 @@ case class SelectQuery(project: QueryIRNode, flags = flags + f this - override def toSQLString(using d: Dialect)(using cnf: Config)(): String = + override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = val flagsStr = if flags.contains(SelectFlags.Distinct) then "DISTINCT " else "" val projectStr = project.toSQLString() val fromStr = from.map(f => f.toSQLString()).mkString("", ", ", "") @@ -299,7 +299,7 @@ case class OrderedQuery(query: RelationOp, sortFn: Seq[(QueryIRNode, Ord)], ast: flags = flags + f this - override def toSQLString(using d: Dialect)(using cnf: Config)(): String = + override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = wrapString(s"${query.toSQLString()} ORDER BY ${sortFn.map(s => val varStr = s._1 match // NOTE: special case orderBy alias since for now, don't bother prefixing, TODO: which prefix to use for multi-relation select? case v: SelectExpr => v.attrName @@ -368,7 +368,7 @@ case class NaryRelationOp(children: Seq[QueryIRNode], op: String, ast: DatabaseA flags = flags + f this - override def toSQLString(using d: Dialect)(using cnf: Config)(): String = + override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = wrapString(children.map(_.toSQLString()).mkString(s" $op ")) case class MultiRecursiveRelationOp(aliases: Seq[String], @@ -400,7 +400,7 @@ case class MultiRecursiveRelationOp(aliases: Seq[String], flags = flags + f this - override def toSQLString(using d: Dialect)(using cnf: Config)(): String = + override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = // NOTE: no parens or alias needed, since already defined val ctes = aliases.zip(query).map((a, q) => s"$a AS (${q.toSQLString()})").mkString(",\n") s"WITH RECURSIVE $ctes\n ${finalQ.toSQLString()}" @@ -409,7 +409,7 @@ case class MultiRecursiveRelationOp(aliases: Seq[String], * A recursive variable that points to a table or subquery. */ case class RecursiveIRVar(pointsToAlias: String, alias: String, ast: DatabaseAST[?]) extends RelationOp: - override def toSQLString(using d: Dialect)(using cnf: Config)() = s"$pointsToAlias as $alias" + override def computeSQLString(using d: Dialect)(using cnf: Config)() = s"$pointsToAlias as $alias" override def toString: String = s"RVAR($alias->$pointsToAlias)" // TODO: for now reuse TableOp's methods @@ -477,7 +477,7 @@ case class GroupByQuery( flags = flags + f this - override def toSQLString(using d: Dialect)(using cnf: Config)(): String = + override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = val flagsStr = if flags.contains(SelectFlags.Distinct) then "DISTINCT " else "" val sourceStr = source.toSQLString() val groupByStr = groupBy.toSQLString() From 7e76307ad562de4be794e7eeb7cde35f722a73e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 18:23:24 +0100 Subject: [PATCH 043/106] readme: current tooling problems --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 5445b07..8025bc0 100644 --- a/README.md +++ b/README.md @@ -107,3 +107,20 @@ SQLite, H2, DuckDB are in-memory, no auth. Postgres, MySQL, MariaDB are localhos This is what a correctly configured DataGrip looks like: ![Correctly Configured DataGrip](documentation/correctly-configured-DataGrip.png) +### Current tooling problems +#### Missing Postgres driver (not urgent) +All works perfectly inside the containers. But when the DBs are up and I invoke `sbt run test` directly from my laptop, the first attempt to connect to Postgres ends with this: +``` +==> X test.integration.booleans.BooleanTests.boolean encoding 0.014s java.sql.SQLException: No suitable driver found for jdbc:postgresql://localhost:5433/testdb + at java.sql.DriverManager.getConnection(DriverManager.java:708) + at java.sql.DriverManager.getConnection(DriverManager.java:230) +``` + +### Containers break VSCode's integration (somewhat urgent) +When you use VSCode with Metals, there are directories `.metals`, `.bloop`, `project/.bloop` which are being used. Of them the bloop directories are also used by the containerized tests. So after you run test in docker, you have to +```sh +rm -rf .metals .bloop project/.bloop +``` +and then restart VSCode and click 'Import project' again in the Metals popup. This takes around 35 seconds. + +This happens because the containers use the project directory, including its internal tooling directories via mounted volume and the IDE and Debian tests keep overwriting these settings. From 5329d4ea30ac2722c89dbd9657ea7612e981edd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 18:25:22 +0100 Subject: [PATCH 044/106] update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8025bc0..d364ea6 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,8 @@ The containerized environment includes: ``` SQLite, H2, DuckDB are in-memory, no auth. Postgres, MySQL, MariaDB are localhost testuser:testpass. Watch out for ports! -- MySQL: 3307 (and not 3306) - PostgreSQL: 5433 (and not 5432) +- MySQL: 3307 (and not 3306) - MariaDB: 3308 (and not 3306) This is what a correctly configured DataGrip looks like: @@ -116,7 +116,7 @@ All works perfectly inside the containers. But when the DBs are up and I invoke at java.sql.DriverManager.getConnection(DriverManager.java:230) ``` -### Containers break VSCode's integration (somewhat urgent) +#### Containers break VSCode's integration (somewhat urgent) When you use VSCode with Metals, there are directories `.metals`, `.bloop`, `project/.bloop` which are being used. Of them the bloop directories are also used by the containerized tests. So after you run test in docker, you have to ```sh rm -rf .metals .bloop project/.bloop From 92ab394a59752e45fd166a1ea7cf94090e83ce43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 18:55:08 +0100 Subject: [PATCH 045/106] add Expr.Minus and workarounds around a scala compiler bug --- src/main/scala/tyql/expr/Expr.scala | 17 +++++++++++++++-- src/main/scala/tyql/ir/QueryIRTree.scala | 1 + 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index 3762aea..be3b9d5 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -59,9 +59,21 @@ object Expr: def <(y: Int): Expr[Boolean, S1] = Lt[S1, NonScalarExpr](x, IntLit(y)) def <=[S2 <: ExprShape] (y: Expr[Int, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = Lte(x, y) def >=[S2 <: ExprShape] (y: Expr[Int, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = Gte(x, y) - - def +[S2 <: ExprShape](y: Expr[Int, S2]): Expr[Int, CalculatedShape[S1, S2]] = Plus(x, y) + @targetName("addIntScalar") + def +(y: Expr[Int, ScalarExpr]): Expr[Int, CalculatedShape[S1, ScalarExpr]] = Plus(x, y) + @targetName("addIntNonScalar") + def +(y: Expr[Int, NonScalarExpr]): Expr[Int, CalculatedShape[S1, NonScalarExpr]] = Plus(x, y) def +(y: Int): Expr[Int, S1] = Plus[S1, NonScalarExpr, Int](x, IntLit(y)) + @targetName("subtractIntScalar") + def -(y: Expr[Int, ScalarExpr]): Expr[Int, CalculatedShape[S1, ScalarExpr]] = Minus(x, y) + @targetName("subtractIntNonScalar") + def -(y: Expr[Int, NonScalarExpr]): Expr[Int, CalculatedShape[S1, NonScalarExpr]] = Minus(x, y) + def -(y: Int): Expr[Int, S1] = Minus[S1, NonScalarExpr, Int](x, IntLit(y)) + @targetName("multiplyIntScalar") + def *(y: Expr[Int, ScalarExpr]): Expr[Int, CalculatedShape[S1, ScalarExpr]] = Times(x, y) + @targetName("multiplyIntNonScalar") + def *(y: Expr[Int, NonScalarExpr]): Expr[Int, CalculatedShape[S1, NonScalarExpr]] = Times(x, y) + def *(y: Int): Expr[Int, S1] = Times(x, IntLit(y)) // TODO: write for numerical extension [S1 <: ExprShape](x: Expr[Double, S1]) @@ -134,6 +146,7 @@ object Expr: case class RawSQLInsert[R](sql: String, replacements: Map[String, Expr[?, ?]] = Map.empty)(using ResultTag[R]) extends Expr[R, NonScalarExpr] // XXX TODO NonScalarExpr? case class Plus[S1 <: ExprShape, S2 <: ExprShape, T: Numeric]($x: Expr[T, S1], $y: Expr[T, S2])(using ResultTag[T]) extends Expr[T, CalculatedShape[S1, S2]] + case class Minus[S1 <: ExprShape, S2 <: ExprShape, T: Numeric]($x: Expr[T, S1], $y: Expr[T, S2])(using ResultTag[T]) extends Expr[T, CalculatedShape[S1, S2]] case class Times[S1 <: ExprShape, S2 <: ExprShape, T: Numeric]($x: Expr[T, S1], $y: Expr[T, S2])(using ResultTag[T]) extends Expr[T, CalculatedShape[S1, S2]] case class And[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Boolean, S1], $y: Expr[Boolean, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] case class Or[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Boolean, S1], $y: Expr[Boolean, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index 636bf26..a104d82 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -357,6 +357,7 @@ object QueryIRTree: case f2: Expr.FunctionCall2[?, ?, ?, ?, ?] => FunctionCallOp(f2.name, Seq(f2.$a1, f2.$a1).map(generateExpr(_, symbols)), f2) case r: Expr.RawSQLInsert[?] => RawSQLInsertOp(r.sql, r.replacements.mapValues(generateExpr(_, symbols)).toMap, Precedence.Default, r) // TODO precedence? case a: Expr.Plus[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l + $r", Precedence.Additive, a) + case a: Expr.Minus[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l - $r", Precedence.Additive, a) case a: Expr.Times[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l * $r", Precedence.Multiplicative, a) case a: Expr.Eq[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l = $r", Precedence.Comparison, a) case a: Expr.Ne[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l <> $r", Precedence.Comparison, a) From 7b13c2147ae56a4438f0507489ec856f66fb4955 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 18:55:43 +0100 Subject: [PATCH 046/106] tests: precedence --- .../scala/test/integration/precedence.scala | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/test/scala/test/integration/precedence.scala diff --git a/src/test/scala/test/integration/precedence.scala b/src/test/scala/test/integration/precedence.scala new file mode 100644 index 0000000..606bf4d --- /dev/null +++ b/src/test/scala/test/integration/precedence.scala @@ -0,0 +1,83 @@ +package test.integration.precedence + +import munit.FunSuite +import test.{withDB, checkExprDialect} +import java.sql.ResultSet +import tyql.Expr + +class PrecedenceTests extends FunSuite { + def expectB(expected: Boolean)(rs: ResultSet) = assertEquals(rs.getBoolean(1), expected) + def expectI(expected: Int)(rs: ResultSet) = assertEquals(rs.getInt(1), expected) + + val t = Expr.BooleanLit(true) + val f = Expr.BooleanLit(false) + val i1 = Expr.IntLit(1) + val i2 = Expr.IntLit(2) + val i3 = Expr.IntLit(3) + + test("boolean precedence -- AND over OR") { + checkExprDialect[Boolean](t || f && f, expectB(true))(withDB.all) + checkExprDialect[Boolean](t && f || t, expectB(true))(withDB.all) + } + + test("boolean precedence -- NOT over AND/OR") { + checkExprDialect[Boolean](!f && t, expectB(true))(withDB.all) + checkExprDialect[Boolean](!t || f, expectB(false))(withDB.all) + checkExprDialect[Boolean](!t && !f, expectB(false))(withDB.all) + } + + test("integer precedence -- multiplication over addition") { + checkExprDialect[Int](i1 + i2 * i3, expectI(7))(withDB.all) + checkExprDialect[Int](i1 * i2 + i3, expectI(5))(withDB.all) + checkExprDialect[Int](i1 * i2 * i3, expectI(6))(withDB.all) + } + + test("integer precedence -- comparison with arithmetic") { + checkExprDialect[Boolean](i1 + i2 > i3, expectB(false))(withDB.all) + checkExprDialect[Boolean](i1 < i2 + i3, expectB(true))(withDB.all) + checkExprDialect[Boolean](i1 * i2 == i3, expectB(false))(withDB.all) + } + + test("mixed precedence -- comparison with boolean ops") { + checkExprDialect[Boolean](i1 < i2 && i2 < i3, expectB(true))(withDB.all) + checkExprDialect[Boolean](i1 < i2 || i3 < i2, expectB(true))(withDB.all) + checkExprDialect[Boolean](i1 + i2 > i3 && t, expectB(false))(withDB.all) + } + + test("boolean precedence -- multiple AND/OR") { + checkExprDialect[Boolean](t || f && f || t, expectB(true))(withDB.all) + checkExprDialect[Boolean](t && f || t && f, expectB(false))(withDB.all) + checkExprDialect[Boolean](t && (f || t) && f, expectB(false))(withDB.all) + } + + test("integer precedence -- multiple operations") { + checkExprDialect[Int](i1 + i2 * i3 - i1, expectI(6))(withDB.all) + checkExprDialect[Int](i1 * (i2 + i3) - i1, expectI(4))(withDB.all) + checkExprDialect[Int](i1 * i2 + i3 * i1, expectI(5))(withDB.all) + } + + test("mixed precedence -- complex expressions") { + checkExprDialect[Boolean]((i1 + i2) * i3 > i1 && t, expectB(true))(withDB.all) + checkExprDialect[Boolean](i1 < i2 + i3 && i2 * i3 > i1, expectB(true))(withDB.all) + checkExprDialect[Boolean](i1 + i2 * i3 == i3 + i1 && t, expectB(false))(withDB.all) + } + + test("boolean precedence -- XOR over AND/OR") { + checkExprDialect[Boolean](t ^ f && t, expectB(true))(withDB.all) + checkExprDialect[Boolean](t && f ^ t, expectB(true))(withDB.all) + checkExprDialect[Boolean](t ^ t || f, expectB(false))(withDB.all) + } + + test("integer precedence -- division and subtraction".ignore) { + // TODO we currently do not handle / as it's very dialect specific and not yet implemented + // checkExprDialect[Int](i3 / i1 - i2, expectI(2))(withDB.all) + // checkExprDialect[Int](i3 - i1 / i2, expectI(2))(withDB.all) + // checkExprDialect[Int](i3 / (i1 - i2), expectI(-3))(withDB.all) + } + + test("mixed precedence -- arithmetic with boolean") { + checkExprDialect[Boolean](i1 + i2 > i3 || t, expectB(true))(withDB.all) + checkExprDialect[Boolean](i1 * i2 < i3 && f, expectB(false))(withDB.all) + checkExprDialect[Boolean](i1 - i2 == i3 || t, expectB(true))(withDB.all) + } +} From 1145015294d4b187394617bf7000deb7ccaa64dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 18:57:32 +0100 Subject: [PATCH 047/106] update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d364ea6..72a8b86 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ All works perfectly inside the containers. But when the DBs are up and I invoke ``` #### Containers break VSCode's integration (somewhat urgent) -When you use VSCode with Metals, there are directories `.metals`, `.bloop`, `project/.bloop` which are being used. Of them the bloop directories are also used by the containerized tests. So after you run test in docker, you have to +When you use VSCode with Metals, there are directories `.metals`, `.bloop`, `project/.bloop` which are being used. Of them the bloop directories are also used by the containerized tests. So after you run test in docker, you have to close the VSCode, ```sh rm -rf .metals .bloop project/.bloop ``` From 6cdf5c23f1c59a7ac441a5267681301a2a587d9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 18:58:10 +0100 Subject: [PATCH 048/106] update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 72a8b86..dfaa516 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,6 @@ When you use VSCode with Metals, there are directories `.metals`, `.bloop`, `pro ```sh rm -rf .metals .bloop project/.bloop ``` -and then restart VSCode and click 'Import project' again in the Metals popup. This takes around 35 seconds. +and then restart VSCode and click 'Import build' again in the Metals popup. This takes around 35 seconds. This happens because the containers use the project directory, including its internal tooling directories via mounted volume and the IDE and Debian tests keep overwriting these settings. From 6cf7f57ca1bca95beabf06413913c8ab04064e51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 19:47:40 +0100 Subject: [PATCH 049/106] tests: broken dialect selection --- ...rrectly between dialects and configs.scala | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/test/scala/test/dialects/queries cached correctly between dialects and configs.scala diff --git a/src/test/scala/test/dialects/queries cached correctly between dialects and configs.scala b/src/test/scala/test/dialects/queries cached correctly between dialects and configs.scala new file mode 100644 index 0000000..533245d --- /dev/null +++ b/src/test/scala/test/dialects/queries cached correctly between dialects and configs.scala @@ -0,0 +1,25 @@ +package test.dialects.cachingworksbetweendialectsandconfigs + +import munit.FunSuite + +class QueryCachingWorksBetweenDialectsAndConfigs extends FunSuite { + + case class Row(i: Int) + val t = tyql.Table[Row]("t") + + // TODO this is currently broken + test("query caching works between dialects and configs".ignore) { + var q: tyql.QueryIRNode = null + { + q = t.map(_ => tyql.Expr.StringLit("abc").charLength).toQueryIR + } + { + import tyql.Dialect.postgresql.given + println(expr.toSQLString()) + } + { + import tyql.Dialect.mysql.given + println(expr.toSQLString()) + } + } +} From 297483c367d9b76260d46456dcf805b1e5c9eb6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 20:13:52 +0100 Subject: [PATCH 050/106] work around a parsing bug in Metals --- src/main/scala/tyql/query/RestrictedQuery.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/scala/tyql/query/RestrictedQuery.scala b/src/main/scala/tyql/query/RestrictedQuery.scala index efab06c..57075ad 100644 --- a/src/main/scala/tyql/query/RestrictedQuery.scala +++ b/src/main/scala/tyql/query/RestrictedQuery.scala @@ -118,6 +118,7 @@ object RestrictedQuery { /** Given a Tuple `(Query[A], Query[B], ...)`, return `(RestrictedQueryRef[A, _, 0], RestrictedQueryRef[B, _, 1], ...)` */ type ToRestrictedQueryRef[QT <: Tuple] = Tuple.Map[ZipWithIndex[Elems[QT]], [T] =>> T match - case (elem, index) => RestrictedQueryRef[elem, SetResult, index]] + case (elem, index) => RestrictedQueryRef[elem, SetResult, index] + ] } From 5779dee8138d18bf5b096bba63a2c83538a32643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 20:14:14 +0100 Subject: [PATCH 051/106] test: toQueryIR, toSQLString caching --- ...rrectly between dialects and configs.scala | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/src/test/scala/test/dialects/queries cached correctly between dialects and configs.scala b/src/test/scala/test/dialects/queries cached correctly between dialects and configs.scala index 533245d..ac0dea5 100644 --- a/src/test/scala/test/dialects/queries cached correctly between dialects and configs.scala +++ b/src/test/scala/test/dialects/queries cached correctly between dialects and configs.scala @@ -1,25 +1,50 @@ package test.dialects.cachingworksbetweendialectsandconfigs import munit.FunSuite +import tyql.Dialect class QueryCachingWorksBetweenDialectsAndConfigs extends FunSuite { case class Row(i: Int) val t = tyql.Table[Row]("t") - // TODO this is currently broken - test("query caching works between dialects and configs".ignore) { - var q: tyql.QueryIRNode = null - { - q = t.map(_ => tyql.Expr.StringLit("abc").charLength).toQueryIR - } + test("query caching between dialects") { + // TODO XXX toQueryIR is dialect-dependent!!! Is this OK or not? + // XXX by changing the number of calls and looking at printouts in the logs you can see that caching works + var q: tyql.Query[?, ?] = null { import tyql.Dialect.postgresql.given - println(expr.toSQLString()) + q = t.map(_ => tyql.Expr.StringLit("abc").charLength) + val ir = q.toQueryIR + val ir2 = q.toQueryIR + val ir3 = q.toQueryIR + val s1 = ir.toSQLString() + val s2 = ir.toSQLString() + val s3 = ir.toSQLString() + val s4 = ir.toSQLString() + val s5 = ir.toSQLString() + assertEquals(s1, s2) + assertEquals(s1, s3) + assertEquals(s1, s4) + assertEquals(s1, s5) + assert(s1.toLowerCase().contains("length("), s1) } { import tyql.Dialect.mysql.given - println(expr.toSQLString()) + // q = t.map(_ => tyql.Expr.StringLit("abc").charLength).toQueryIR // we do not regenerate + val ir = q.toQueryIR + val ir2 = q.toQueryIR + val ir3 = q.toQueryIR + val s1 = ir.toSQLString() + val s2 = ir.toSQLString() + val s3 = ir.toSQLString() + val s4 = ir.toSQLString() + val s5 = ir.toSQLString() + assertEquals(s1, s2) + assertEquals(s1, s3) + assertEquals(s1, s4) + assertEquals(s1, s5) + assert(s1.toLowerCase().contains("char_length("), s1) } } } From c29add2916877c917812285228018ff5a265640e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 20:25:24 +0100 Subject: [PATCH 052/106] comments: todos --- src/main/scala/tyql/dialects/dialects.scala | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index 9b15e50..1edc40a 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -47,6 +47,7 @@ object Dialect: given RandomFloat = new RandomFloat(Some("random")) {} given RandomUUID = new RandomUUID("gen_random_uuid") {} + // TODO now that we have precedence, fix the parenthesization rules for this! given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"floor(random() * ($b - $a + 1) + $a)::integer") {} object mysql: @@ -61,6 +62,7 @@ object Dialect: given RandomFloat = new RandomFloat(Some("rand")) {} given RandomUUID = new RandomUUID("UUID") {} + // TODO now that we have precedence, fix the parenthesization rules for this! given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"floor(rand() * ($b - $a + 1) + $a)") {} object mariadb: @@ -82,8 +84,10 @@ object Dialect: def name() = "SQLite Dialect" override val stringLengthByCharacters = "length" + // TODO think about how quoting strings like this impacts simplifications and efficient generation given RandomFloat = new RandomFloat(None, Some("(0.5 - RANDOM() / CAST(-9223372036854775808 AS REAL) / 2)")) {} - given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"cast(abs(random() % ($b - $a + 1) + $a) as integer)") {} // TODO think about how this impacts simplifications and efficient generation + // TODO now that we have precedence, fix the parenthesization rules for this! + given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"cast(abs(random() % ($b - $a + 1) + $a) as integer)") {} object h2: given Dialect = new Dialect @@ -96,6 +100,7 @@ object Dialect: given RandomFloat = new RandomFloat(Some("rand")) {} given RandomUUID = new RandomUUID("RANDOM_UUID") {} + // TODO now that we have precedence, fix the parenthesization rules for this! given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"floor(rand() * ($b - $a + 1) + $a)") {} object duckdb: @@ -110,4 +115,5 @@ object Dialect: given RandomFloat = new RandomFloat(Some("random")) {} given RandomUUID = new RandomUUID("uuid") {} + // TODO now that we have precedence, fix the parenthesization rules for this! given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"floor(random() * ($b - $a + 1) + $a)::integer") {} From 83f100098b48a8956405b9f8eda858cdd0f9e8e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 20:26:08 +0100 Subject: [PATCH 053/106] whitespace --- src/main/scala/tyql/dialects/dialects.scala | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index 1edc40a..eb7f4cc 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -44,7 +44,6 @@ object Dialect: def name() = "PostgreSQL Dialect" override val stringLengthByCharacters: String = "length" - given RandomFloat = new RandomFloat(Some("random")) {} given RandomUUID = new RandomUUID("gen_random_uuid") {} // TODO now that we have precedence, fix the parenthesization rules for this! From c0d6971fd67097fed16bb5cf0dcc2b46da7f26df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 19 Nov 2024 21:00:04 +0100 Subject: [PATCH 054/106] dialects: random* operations are now late-binding --- .../tyql/dialects/dialect features.scala | 6 +- src/main/scala/tyql/dialects/dialects.scala | 41 ++++++-- src/main/scala/tyql/expr/Expr.scala | 13 ++- src/main/scala/tyql/ir/QueryIRTree.scala | 7 ++ src/test/scala/test/TestSingeExpr.scala | 12 ++- src/test/scala/test/integration/random.scala | 96 ++++++++++++++----- 6 files changed, 128 insertions(+), 47 deletions(-) diff --git a/src/main/scala/tyql/dialects/dialect features.scala b/src/main/scala/tyql/dialects/dialect features.scala index 0d1533f..b9a244c 100644 --- a/src/main/scala/tyql/dialects/dialect features.scala +++ b/src/main/scala/tyql/dialects/dialect features.scala @@ -3,7 +3,7 @@ package tyql trait DialectFeature object DialectFeature: - trait RandomFloat(val funName: Option[String], val rawSQL: Option[String] = None) extends DialectFeature: - assert(funName.isDefined == !rawSQL.isDefined) - trait RandomUUID(val funName: String) extends DialectFeature + trait RandomFloat extends DialectFeature + trait RandomUUID extends DialectFeature + // TODO also refactor this one just like the above two are refactored to be late-binding trait RandomIntegerInInclusiveRange(val expr: (String, String) => String) extends DialectFeature // TODO later change it to not use raw SQL maybe? diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index eb7f4cc..e44f47c 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -5,6 +5,9 @@ import tyql.DialectFeature.* // TODO which of these should be sealed? Do we support custom dialectes? +private def unsupportedFeature(feature: String) = + throw new UnsupportedOperationException(s"$feature feature not supported in this dialect!") + trait Dialect: def name(): String @@ -21,6 +24,11 @@ trait Dialect: val xorOperatorSupportedNatively = false + def feature_RandomUUID_functionName: String = unsupportedFeature("RandomUUID") + def feature_RandomFloat_functionName: Option[String] = unsupportedFeature("RandomFloat") + def feature_RandomFloat_rawSQL: Option[String] = unsupportedFeature("RandomFloat") + + object Dialect: val literal_percent = '\uE000' val literal_underscore = '\uE001' @@ -43,9 +51,12 @@ object Dialect: with BooleanLiterals.UseTrueFalse: def name() = "PostgreSQL Dialect" override val stringLengthByCharacters: String = "length" + override def feature_RandomUUID_functionName: String = "gen_random_uuid" + override def feature_RandomFloat_functionName: Option[String] = Some("random") + override def feature_RandomFloat_rawSQL: Option[String] = None - given RandomFloat = new RandomFloat(Some("random")) {} - given RandomUUID = new RandomUUID("gen_random_uuid") {} + given RandomFloat = new RandomFloat {} + given RandomUUID = new RandomUUID {} // TODO now that we have precedence, fix the parenthesization rules for this! given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"floor(random() * ($b - $a + 1) + $a)::integer") {} @@ -58,9 +69,12 @@ object Dialect: with BooleanLiterals.UseTrueFalse: def name() = "MySQL Dialect" override val xorOperatorSupportedNatively = true + override def feature_RandomUUID_functionName: String = "UUID" + override def feature_RandomFloat_functionName: Option[String] = Some("rand") + override def feature_RandomFloat_rawSQL: Option[String] = None - given RandomFloat = new RandomFloat(Some("rand")) {} - given RandomUUID = new RandomUUID("UUID") {} + given RandomFloat = new RandomFloat {} + given RandomUUID = new RandomUUID {} // TODO now that we have precedence, fix the parenthesization rules for this! given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"floor(rand() * ($b - $a + 1) + $a)") {} @@ -82,9 +96,12 @@ object Dialect: with BooleanLiterals.UseTrueFalse: def name() = "SQLite Dialect" override val stringLengthByCharacters = "length" + override def feature_RandomFloat_functionName: Option[String] = None + // TODO now that we have precedence, fix the parenthesization rules for this! + override def feature_RandomFloat_rawSQL: Option[String] = Some("(0.5 - RANDOM() / CAST(-9223372036854775808 AS REAL) / 2)") // TODO think about how quoting strings like this impacts simplifications and efficient generation - given RandomFloat = new RandomFloat(None, Some("(0.5 - RANDOM() / CAST(-9223372036854775808 AS REAL) / 2)")) {} + given RandomFloat = new RandomFloat {} // TODO now that we have precedence, fix the parenthesization rules for this! given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"cast(abs(random() % ($b - $a + 1) + $a) as integer)") {} @@ -96,9 +113,12 @@ object Dialect: with BooleanLiterals.UseTrueFalse: def name() = "H2 Dialect" override val stringLengthByCharacters = "length" + override def feature_RandomUUID_functionName: String = "RANDOM_UUID" + override def feature_RandomFloat_functionName: Option[String] = Some("rand") + override def feature_RandomFloat_rawSQL: Option[String] = None - given RandomFloat = new RandomFloat(Some("rand")) {} - given RandomUUID = new RandomUUID("RANDOM_UUID") {} + given RandomFloat = new RandomFloat {} + given RandomUUID = new RandomUUID {} // TODO now that we have precedence, fix the parenthesization rules for this! given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"floor(rand() * ($b - $a + 1) + $a)") {} @@ -111,8 +131,11 @@ object Dialect: override def name(): String = "DuckDB Dialect" override val stringLengthByCharacters = "length" override val stringLengthByBytes = Seq("encode", "octet_length") + override def feature_RandomUUID_functionName: String = "uuid" + override def feature_RandomFloat_functionName: Option[String] = Some("random") + override def feature_RandomFloat_rawSQL: Option[String] = None - given RandomFloat = new RandomFloat(Some("random")) {} - given RandomUUID = new RandomUUID("uuid") {} + given RandomFloat = new RandomFloat {} + given RandomUUID = new RandomUUID {} // TODO now that we have precedence, fix the parenthesization rules for this! given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"floor(random() * ($b - $a + 1) + $a)::integer") {} diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index be3b9d5..53cbc7b 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -158,6 +158,10 @@ object Expr: case class StringCharLength[S <: ExprShape]($x: Expr[String, S]) extends Expr[Int, S] case class StringByteLength[S <: ExprShape]($x: Expr[String, S]) extends Expr[Int, S] + case class RandomUUID() extends Expr[String, NonScalarExpr] // XXX NonScalarExpr? + case class RandomFloat() extends Expr[Double, NonScalarExpr] // XXX NonScalarExpr? + case class RandomInt[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Int, S1], $y: Expr[Int, S2]) extends Expr[Int, CalculatedShape[S1, S2]] + case class ListExpr[A]($elements: List[Expr[A, NonScalarExpr]])(using ResultTag[List[A]]) extends Expr[List[A], NonScalarExpr] extension [A, E <: Expr[A, NonScalarExpr]](x: List[E]) def toExpr(using ResultTag[List[A]]): ListExpr[A] = ListExpr(x) @@ -229,15 +233,10 @@ object Expr: // time of writing the expression, the dialect needs to be selected, despite the fact that // this feature is implemented across most dialects. def randomFloat(using r: DialectFeature.RandomFloat)(): Expr[Double, NonScalarExpr] = - if r.rawSQL.isDefined then - RawSQLInsert[Double](r.rawSQL.get) - else if r.funName.isDefined then - FunctionCall0[Double](r.funName.get) - else - assert(false, "RandomFloat dialect feature must have either a function name or raw SQL") + RandomFloat() def randomUUID(using r: DialectFeature.RandomUUID)(): Expr[String, NonScalarExpr] = - FunctionCall0[String](r.funName) + RandomUUID() def randomInt(a: Expr[Int, ?], b: Expr[Int, ?])(using r: DialectFeature.RandomIntegerInInclusiveRange): Expr[Int, NonScalarExpr] = // TODO maybe add a check for (a <= b) if we know both components at generation time? diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index a104d82..3880de6 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -356,6 +356,13 @@ object QueryIRTree: case f1: Expr.FunctionCall1[?, ?, ?] => FunctionCallOp(f1.name, Seq(generateExpr(f1.$a1, symbols)), f1) case f2: Expr.FunctionCall2[?, ?, ?, ?, ?] => FunctionCallOp(f2.name, Seq(f2.$a1, f2.$a1).map(generateExpr(_, symbols)), f2) case r: Expr.RawSQLInsert[?] => RawSQLInsertOp(r.sql, r.replacements.mapValues(generateExpr(_, symbols)).toMap, Precedence.Default, r) // TODO precedence? + case u: Expr.RandomUUID => FunctionCallOp(d.feature_RandomUUID_functionName, Seq(), u) + case f: Expr.RandomFloat => + assert(d.feature_RandomFloat_functionName.isDefined != d.feature_RandomFloat_rawSQL.isDefined, "RandomFloat dialect feature must have either a function name or raw SQL") + if d.feature_RandomFloat_functionName.isDefined then + FunctionCallOp(d.feature_RandomFloat_functionName.get, Seq(), f) + else + RawSQLInsertOp(d.feature_RandomFloat_rawSQL.get, Map(), Precedence.Default, f) // TODO better precedence here case a: Expr.Plus[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l + $r", Precedence.Additive, a) case a: Expr.Minus[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l - $r", Precedence.Additive, a) case a: Expr.Times[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l * $r", Precedence.Multiplicative, a) diff --git a/src/test/scala/test/TestSingeExpr.scala b/src/test/scala/test/TestSingeExpr.scala index e92bbeb..7cef5ad 100644 --- a/src/test/scala/test/TestSingeExpr.scala +++ b/src/test/scala/test/TestSingeExpr.scala @@ -9,7 +9,7 @@ import tyql.{NonScalarExpr, ResultTag} def checkExpr[A](using ResultTag[A]) - (expr: tyql.Expr[A, NonScalarExpr], checkValue: ResultSet => Unit) + (expr: tyql.Expr[A, NonScalarExpr], checkValue: ResultSet => Unit, sqlCallback: String => Unit = _ => ()) (runner: (f: Connection => Unit) => Unit): Unit = { case class Row(i: Int) val t = Table[Row]("table59175810544") @@ -18,7 +18,9 @@ def checkExpr[A](using ResultTag[A]) stmt.executeUpdate(s"DROP TABLE IF EXISTS table59175810544;") stmt.executeUpdate(s"CREATE TABLE table59175810544 (i INTEGER);") stmt.executeUpdate(s"INSERT INTO table59175810544 (i) VALUES (1);") - val rs = stmt.executeQuery(t.map(_ => expr).toQueryIR.toSQLString()) + val sqlQueryString = t.map(_ => expr).toQueryIR.toSQLString() + sqlCallback(sqlQueryString) + val rs = stmt.executeQuery(sqlQueryString) assert(rs.next()) checkValue(rs) stmt.executeUpdate(s"DROP TABLE IF EXISTS table59175810544;") @@ -26,7 +28,7 @@ def checkExpr[A](using ResultTag[A]) } def checkExprDialect[A](using ResultTag[A]) - (expr: tyql.Expr[A, NonScalarExpr], checkValue: ResultSet => Unit) + (expr: tyql.Expr[A, NonScalarExpr], checkValue: ResultSet => Unit, sqlCallback: String => Unit = _ => ()) (runner: (f: Connection => Dialect ?=> Unit) => Unit): Unit = { case class Row(i: Int) val t = Table[Row]("table59175810544") @@ -35,7 +37,9 @@ def checkExprDialect[A](using ResultTag[A]) stmt.executeUpdate(s"DROP TABLE IF EXISTS table59175810544;") stmt.executeUpdate(s"CREATE TABLE table59175810544 (i INTEGER);") stmt.executeUpdate(s"INSERT INTO table59175810544 (i) VALUES (1);") - val rs = stmt.executeQuery(t.map(_ => expr).toQueryIR.toSQLString()) + val sqlQueryString = t.map(_ => expr).toQueryIR.toSQLString() + sqlCallback(sqlQueryString) + val rs = stmt.executeQuery(sqlQueryString) assert(rs.next()) checkValue(rs) stmt.executeUpdate(s"DROP TABLE IF EXISTS table59175810544;") diff --git a/src/test/scala/test/integration/random.scala b/src/test/scala/test/integration/random.scala index ca8f998..6c51688 100644 --- a/src/test/scala/test/integration/random.scala +++ b/src/test/scala/test/integration/random.scala @@ -1,7 +1,7 @@ package test.integration.random import munit.FunSuite -import test.{withDBNoImplicits, checkExpr} +import test.{withDB, checkExprDialect} import java.sql.{Connection, Statement, ResultSet} import tyql.{Dialect, Table, Expr} @@ -14,27 +14,52 @@ class RandomTests extends FunSuite { { import Dialect.postgresql.given - checkExpr[Double](Expr.randomFloat(), checkValue)(withDBNoImplicits.postgres) + checkExprDialect[Double](Expr.randomFloat(), checkValue)(withDB.postgres) } { import Dialect.mysql.given - checkExpr[Double](Expr.randomFloat(), checkValue)(withDBNoImplicits.mysql) + checkExprDialect[Double](Expr.randomFloat(), checkValue)(withDB.mysql) } { import Dialect.mariadb.given - checkExpr[Double](Expr.randomFloat(), checkValue)(withDBNoImplicits.mariadb) + checkExprDialect[Double](Expr.randomFloat(), checkValue)(withDB.mariadb) } { import Dialect.duckdb.given - checkExpr[Double](Expr.randomFloat(), checkValue)(withDBNoImplicits.duckdb) + checkExprDialect[Double](Expr.randomFloat(), checkValue)(withDB.duckdb) } { import Dialect.h2.given - checkExpr[Double](Expr.randomFloat(), checkValue)(withDBNoImplicits.h2) + checkExprDialect[Double](Expr.randomFloat(), checkValue)(withDB.h2) } { import Dialect.sqlite.given - checkExpr[Double](Expr.randomFloat(), checkValue)(withDBNoImplicits.sqlite) + checkExprDialect[Double](Expr.randomFloat(), checkValue)(withDB.sqlite) + } + } + + test("randomFloat late binding") { + val checkValue = { (rs: ResultSet) => + val r = rs.getDouble(1) + assert(0 <= r && r <= 1) + } + + var q: tyql.Expr[Double, tyql.NonScalarExpr] = null + { + // XXX you can program against a feature set, not any specific dialect! + // TODO but the syntax for now is ugly... + import tyql.DialectFeature.RandomFloat + given RandomFloat = new RandomFloat {} + q = Expr.randomFloat() + } + + { + import Dialect.postgresql.given + checkExprDialect[Double](q, checkValue, s => assert(s.toLowerCase().contains("random(")))(withDB.postgres) + } + { + import Dialect.mysql.given + checkExprDialect[Double](q, checkValue, s => assert(s.toLowerCase().contains("rand(")))(withDB.mysql) } } @@ -46,23 +71,46 @@ class RandomTests extends FunSuite { { import Dialect.postgresql.given - checkExpr[String](Expr.randomUUID(), checkValue)(withDBNoImplicits.postgres) + checkExprDialect[String](Expr.randomUUID(), checkValue)(withDB.postgres) } { import Dialect.mysql.given - checkExpr[String](Expr.randomUUID(), checkValue)(withDBNoImplicits.mysql) + checkExprDialect[String](Expr.randomUUID(), checkValue)(withDB.mysql) } { import Dialect.mariadb.given - checkExpr[String](Expr.randomUUID(), checkValue)(withDBNoImplicits.mariadb) + checkExprDialect[String](Expr.randomUUID(), checkValue)(withDB.mariadb) } { import Dialect.duckdb.given - checkExpr[String](Expr.randomUUID(), checkValue)(withDBNoImplicits.duckdb) + checkExprDialect[String](Expr.randomUUID(), checkValue)(withDB.duckdb) } { import Dialect.h2.given - checkExpr[String](Expr.randomUUID(), checkValue)(withDBNoImplicits.h2) + checkExprDialect[String](Expr.randomUUID(), checkValue)(withDB.h2) + } + } + + test("randomUUID late binding") { + val checkValue = { (rs: ResultSet) => + val r = rs.getString(1) + assert(r.matches("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")) + } + + var q: tyql.Expr[String, tyql.NonScalarExpr] = null + { + import tyql.DialectFeature.RandomUUID + given RandomUUID = new RandomUUID {} + q = Expr.randomUUID() + } + + { + import Dialect.postgresql.given + checkExprDialect[String](q, checkValue, s => assert(s.toLowerCase().contains("gen_random_uuid(")))(withDB.postgres) + } + { + import Dialect.mysql.given + checkExprDialect[String](q, checkValue, s => assert(s.toLowerCase().contains("uuid(")))(withDB.mysql) } } @@ -81,33 +129,33 @@ class RandomTests extends FunSuite { { import Dialect.postgresql.given - checkExpr[Int](Expr.randomInt(0, 2), checkValue)(withDBNoImplicits.postgres) - checkExpr[Int](Expr.randomInt(44, 44), checkInclusion)(withDBNoImplicits.postgres) + checkExprDialect[Int](Expr.randomInt(0, 2), checkValue)(withDB.postgres) + checkExprDialect[Int](Expr.randomInt(44, 44), checkInclusion)(withDB.postgres) } { import Dialect.mysql.given - checkExpr[Int](Expr.randomInt(0, 2), checkValue)(withDBNoImplicits.mysql) - checkExpr[Int](Expr.randomInt(44, 44), checkInclusion)(withDBNoImplicits.mysql) + checkExprDialect[Int](Expr.randomInt(0, 2), checkValue)(withDB.mysql) + checkExprDialect[Int](Expr.randomInt(44, 44), checkInclusion)(withDB.mysql) } { import Dialect.mariadb.given - checkExpr[Int](Expr.randomInt(0, 2), checkValue)(withDBNoImplicits.mariadb) - checkExpr[Int](Expr.randomInt(44, 44), checkInclusion)(withDBNoImplicits.mariadb) + checkExprDialect[Int](Expr.randomInt(0, 2), checkValue)(withDB.mariadb) + checkExprDialect[Int](Expr.randomInt(44, 44), checkInclusion)(withDB.mariadb) } { import Dialect.duckdb.given - checkExpr[Int](Expr.randomInt(0, 2), checkValue)(withDBNoImplicits.duckdb) - checkExpr[Int](Expr.randomInt(44, 44), checkInclusion)(withDBNoImplicits.duckdb) + checkExprDialect[Int](Expr.randomInt(0, 2), checkValue)(withDB.duckdb) + checkExprDialect[Int](Expr.randomInt(44, 44), checkInclusion)(withDB.duckdb) } { import Dialect.h2.given - checkExpr[Int](Expr.randomInt(0, 2), checkValue)(withDBNoImplicits.h2) - checkExpr[Int](Expr.randomInt(44, 44), checkInclusion)(withDBNoImplicits.h2) + checkExprDialect[Int](Expr.randomInt(0, 2), checkValue)(withDB.h2) + checkExprDialect[Int](Expr.randomInt(44, 44), checkInclusion)(withDB.h2) } { import Dialect.sqlite.given - checkExpr[Int](Expr.randomInt(0, 2), checkValue)(withDBNoImplicits.sqlite) - checkExpr[Int](Expr.randomInt(44, 44), checkInclusion)(withDBNoImplicits.sqlite) + checkExprDialect[Int](Expr.randomInt(0, 2), checkValue)(withDB.sqlite) + checkExprDialect[Int](Expr.randomInt(44, 44), checkInclusion)(withDB.sqlite) } } } From 90c889f195cedd255a6e06c85ac34b10f60618e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Wed, 20 Nov 2024 11:08:47 +0100 Subject: [PATCH 055/106] dialect: finish refactoring of random --- .../tyql/dialects/dialect features.scala | 2 +- src/main/scala/tyql/dialects/dialects.scala | 16 +++++++++---- src/main/scala/tyql/expr/Expr.scala | 9 ++------ src/main/scala/tyql/ir/QueryIRTree.scala | 8 +++++++ src/test/scala/test/integration/random.scala | 23 +++++++++++++++++++ 5 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/main/scala/tyql/dialects/dialect features.scala b/src/main/scala/tyql/dialects/dialect features.scala index b9a244c..c902680 100644 --- a/src/main/scala/tyql/dialects/dialect features.scala +++ b/src/main/scala/tyql/dialects/dialect features.scala @@ -6,4 +6,4 @@ object DialectFeature: trait RandomFloat extends DialectFeature trait RandomUUID extends DialectFeature // TODO also refactor this one just like the above two are refactored to be late-binding - trait RandomIntegerInInclusiveRange(val expr: (String, String) => String) extends DialectFeature // TODO later change it to not use raw SQL maybe? + trait RandomIntegerInInclusiveRange extends DialectFeature // TODO later change it to not use raw SQL maybe? diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index e44f47c..909cae8 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -27,6 +27,7 @@ trait Dialect: def feature_RandomUUID_functionName: String = unsupportedFeature("RandomUUID") def feature_RandomFloat_functionName: Option[String] = unsupportedFeature("RandomFloat") def feature_RandomFloat_rawSQL: Option[String] = unsupportedFeature("RandomFloat") + def feature_RandomInt_rawSQL(a: String, b: String): String = unsupportedFeature("RandomInt") object Dialect: @@ -54,11 +55,12 @@ object Dialect: override def feature_RandomUUID_functionName: String = "gen_random_uuid" override def feature_RandomFloat_functionName: Option[String] = Some("random") override def feature_RandomFloat_rawSQL: Option[String] = None + override def feature_RandomInt_rawSQL(a: String, b: String): String = s"floor(random() * ($b - $a + 1) + $a)::integer" given RandomFloat = new RandomFloat {} given RandomUUID = new RandomUUID {} // TODO now that we have precedence, fix the parenthesization rules for this! - given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"floor(random() * ($b - $a + 1) + $a)::integer") {} + given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} object mysql: given Dialect = new MySQLDialect @@ -72,11 +74,12 @@ object Dialect: override def feature_RandomUUID_functionName: String = "UUID" override def feature_RandomFloat_functionName: Option[String] = Some("rand") override def feature_RandomFloat_rawSQL: Option[String] = None + override def feature_RandomInt_rawSQL(a: String, b: String): String = s"floor(rand() * ($b - $a + 1) + $a)" given RandomFloat = new RandomFloat {} given RandomUUID = new RandomUUID {} // TODO now that we have precedence, fix the parenthesization rules for this! - given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"floor(rand() * ($b - $a + 1) + $a)") {} + given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} object mariadb: // XXX MariaDB extends MySQL @@ -99,11 +102,12 @@ object Dialect: override def feature_RandomFloat_functionName: Option[String] = None // TODO now that we have precedence, fix the parenthesization rules for this! override def feature_RandomFloat_rawSQL: Option[String] = Some("(0.5 - RANDOM() / CAST(-9223372036854775808 AS REAL) / 2)") + override def feature_RandomInt_rawSQL(a: String, b: String): String = s"cast(abs(random() % ($b - $a + 1) + $a) as integer)" // TODO think about how quoting strings like this impacts simplifications and efficient generation given RandomFloat = new RandomFloat {} // TODO now that we have precedence, fix the parenthesization rules for this! - given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"cast(abs(random() % ($b - $a + 1) + $a) as integer)") {} + given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} object h2: given Dialect = new Dialect @@ -116,11 +120,12 @@ object Dialect: override def feature_RandomUUID_functionName: String = "RANDOM_UUID" override def feature_RandomFloat_functionName: Option[String] = Some("rand") override def feature_RandomFloat_rawSQL: Option[String] = None + override def feature_RandomInt_rawSQL(a: String, b: String): String = s"floor(rand() * ($b - $a + 1) + $a)" given RandomFloat = new RandomFloat {} given RandomUUID = new RandomUUID {} // TODO now that we have precedence, fix the parenthesization rules for this! - given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"floor(rand() * ($b - $a + 1) + $a)") {} + given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} object duckdb: given Dialect = new Dialect @@ -134,8 +139,9 @@ object Dialect: override def feature_RandomUUID_functionName: String = "uuid" override def feature_RandomFloat_functionName: Option[String] = Some("random") override def feature_RandomFloat_rawSQL: Option[String] = None + override def feature_RandomInt_rawSQL(a: String, b: String): String = s"floor(random() * ($b - $a + 1) + $a)::integer" given RandomFloat = new RandomFloat {} given RandomUUID = new RandomUUID {} // TODO now that we have precedence, fix the parenthesization rules for this! - given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange((a,b) => s"floor(random() * ($b - $a + 1) + $a)::integer") {} + given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index 53cbc7b..127703e 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -229,21 +229,16 @@ object Expr: // given Conversion[Boolean, BooleanLit] = BooleanLit(_) // TODO why does this break things? - // TODO this presents an interesting choice, using the exact function names hare means that at the - // time of writing the expression, the dialect needs to be selected, despite the fact that - // this feature is implemented across most dialects. def randomFloat(using r: DialectFeature.RandomFloat)(): Expr[Double, NonScalarExpr] = RandomFloat() def randomUUID(using r: DialectFeature.RandomUUID)(): Expr[String, NonScalarExpr] = RandomUUID() - def randomInt(a: Expr[Int, ?], b: Expr[Int, ?])(using r: DialectFeature.RandomIntegerInInclusiveRange): Expr[Int, NonScalarExpr] = + def randomInt[S1 <: ExprShape, S2 <: ExprShape](a: Expr[Int, S1], b: Expr[Int, S2])(using r: DialectFeature.RandomIntegerInInclusiveRange): Expr[Int, CalculatedShape[S1, S2]] = // TODO maybe add a check for (a <= b) if we know both components at generation time? // TODO what about parentheses? Do we really not need them? - val aStr = "A82139520369" - val bStr = "B27604933360" - RawSQLInsert[Int](r.expr(aStr, bStr), Map(aStr -> a, bStr -> b)) + RandomInt(a, b) /** Should be able to rely on the implicit conversions, but not always. * One approach is to overload, another is to provide a user-facing toExpr diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index 3880de6..aa8c865 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -363,6 +363,14 @@ object QueryIRTree: FunctionCallOp(d.feature_RandomFloat_functionName.get, Seq(), f) else RawSQLInsertOp(d.feature_RandomFloat_rawSQL.get, Map(), Precedence.Default, f) // TODO better precedence here + case i: Expr.RandomInt[?, ?] => + val aStr = "A82139520369" + val bStr = "B27604933360" + RawSQLInsertOp( + d.feature_RandomInt_rawSQL(aStr, bStr), + Map(aStr -> generateExpr(i.$x, symbols), bStr -> generateExpr(i.$y, symbols)), + Precedence.Unary, + i) // TODO better precedence here case a: Expr.Plus[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l + $r", Precedence.Additive, a) case a: Expr.Minus[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l - $r", Precedence.Additive, a) case a: Expr.Times[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l * $r", Precedence.Multiplicative, a) diff --git a/src/test/scala/test/integration/random.scala b/src/test/scala/test/integration/random.scala index 6c51688..1618f1e 100644 --- a/src/test/scala/test/integration/random.scala +++ b/src/test/scala/test/integration/random.scala @@ -158,4 +158,27 @@ class RandomTests extends FunSuite { checkExprDialect[Int](Expr.randomInt(44, 44), checkInclusion)(withDB.sqlite) } } + + test("randomInt late binding") { + val checkInclusion = { (rs: ResultSet) => + val r = rs.getInt(1) + assertEquals(r, 101) + } + + var q: tyql.Expr[Int, tyql.NonScalarExpr] = null + { + import tyql.DialectFeature.RandomIntegerInInclusiveRange + given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} + q = Expr.randomInt(101, 101) + } + + { + import Dialect.h2.given + checkExprDialect[Int](q, checkInclusion, s => assert(s.toLowerCase().contains("floor(")))(withDB.h2) + } + { + import Dialect.sqlite.given + checkExprDialect[Int](q, checkInclusion, s => assert(s.toLowerCase().contains("cast(")))(withDB.sqlite) + } + } } From 7a575089c8265513e567e1fdeae9b66b1cbcfc1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Thu, 21 Nov 2024 12:16:14 +0100 Subject: [PATCH 056/106] does markdown handle comments? --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index dfaa516..ab38dc4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # tyql + + + ## Development ### Running Tests Tests are untagged by default or tagged as expensive. From 1bf34d5f41b51897492b8203fd207397078c42d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Thu, 21 Nov 2024 16:24:17 +0100 Subject: [PATCH 057/106] create documentation/development.md --- README.md | 128 ------------------------- documentation/development.md | 178 +++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 128 deletions(-) create mode 100644 documentation/development.md diff --git a/README.md b/README.md index ab38dc4..4b489d4 100644 --- a/README.md +++ b/README.md @@ -1,129 +1 @@ # tyql - - - - -## Development -### Running Tests -Tests are untagged by default or tagged as expensive. -```scala -import test.needsDBs -test("PostgreSQL responds".tag(needsDBs)) { ??? } -``` - -```bash -# Run all tests (both cheap and expensive) -sbt test -# Run only expensive tests -sbt "testOnly -- --include-tags=Expensive" -# Run only cheap tests -sbt "testOnly -- --exclude-tags=Expensive" -``` - -## Containerization - -We provide a `dev.sh` script to manage the development environment using Docker Compose. -```bash -# Start all required databases -./dev.sh db-start -# Stop all databases -./dev.sh db-stop -# Run all tests -./dev.sh test -``` -For convenience, bash completion is provided for the `dev.sh` script. To enable it: -```bash -# Add this to your ~/.bashrc or ~/.bash_profile -source /path/to/project/dev.sh.completion -``` -After enabling completion, you can use Tab to autocomplete `dev.sh` commands: -```bash -./dev.sh -# Shows: db-start db-stop test -``` -Test results from Docker are automatically saved to the `test-results` directory with timestamps. - -The containerized environment includes: -- PostgreSQL (port 5433) -- MySQL (port 3307) -- MariaDB (port 3308) -- SQLite, DuckDB, H2 (in-memory from the main container) - -### DataGrip IDE - -`.idea/dataSources.xml` -```xml - - - - - mysql.8 - true - com.mysql.cj.jdbc.Driver - jdbc:mysql://localhost:3307 - $ProjectFileDir$ - - - h2.unified - true - org.h2.Driver - jdbc:h2:mem:default - $ProjectFileDir$ - - - postgresql - true - org.postgresql.Driver - jdbc:postgresql://localhost:5433/testdb - $ProjectFileDir$ - - - mariadb - true - org.mariadb.jdbc.Driver - jdbc:mariadb://localhost:3308 - $ProjectFileDir$ - - - sqlite.xerial - true - org.sqlite.JDBC - jdbc:sqlite::memory: - $ProjectFileDir$ - - - duckdb - true - org.duckdb.DuckDBDriver - jdbc:duckdb: - $ProjectFileDir$ - - - -``` - -SQLite, H2, DuckDB are in-memory, no auth. Postgres, MySQL, MariaDB are localhost testuser:testpass. Watch out for ports! -- PostgreSQL: 5433 (and not 5432) -- MySQL: 3307 (and not 3306) -- MariaDB: 3308 (and not 3306) - -This is what a correctly configured DataGrip looks like: -![Correctly Configured DataGrip](documentation/correctly-configured-DataGrip.png) - -### Current tooling problems -#### Missing Postgres driver (not urgent) -All works perfectly inside the containers. But when the DBs are up and I invoke `sbt run test` directly from my laptop, the first attempt to connect to Postgres ends with this: -``` -==> X test.integration.booleans.BooleanTests.boolean encoding 0.014s java.sql.SQLException: No suitable driver found for jdbc:postgresql://localhost:5433/testdb - at java.sql.DriverManager.getConnection(DriverManager.java:708) - at java.sql.DriverManager.getConnection(DriverManager.java:230) -``` - -#### Containers break VSCode's integration (somewhat urgent) -When you use VSCode with Metals, there are directories `.metals`, `.bloop`, `project/.bloop` which are being used. Of them the bloop directories are also used by the containerized tests. So after you run test in docker, you have to close the VSCode, -```sh -rm -rf .metals .bloop project/.bloop -``` -and then restart VSCode and click 'Import build' again in the Metals popup. This takes around 35 seconds. - -This happens because the containers use the project directory, including its internal tooling directories via mounted volume and the IDE and Debian tests keep overwriting these settings. diff --git a/documentation/development.md b/documentation/development.md new file mode 100644 index 0000000..bb89e30 --- /dev/null +++ b/documentation/development.md @@ -0,0 +1,178 @@ +# Developer Documentation + +## Current tooling problems +#### Missing Postgres driver (not urgent) +All works perfectly inside the containers. But when the DBs are up and I invoke `sbt run test` directly from my laptop, the first attempt to connect to Postgres ends with this: +``` +==> X test.integration.booleans.BooleanTests.boolean encoding 0.014s java.sql.SQLException: No suitable driver found for jdbc:postgresql://localhost:5433/testdb + at java.sql.DriverManager.getConnection(DriverManager.java:708) + at java.sql.DriverManager.getConnection(DriverManager.java:230) +``` + +#### Containers break VSCode's integration (somewhat urgent) +When you use VSCode with Metals, there are directories `.metals`, `.bloop`, `project/.bloop` which are being used. Of them the bloop directories are also used by the containerized tests. So after you run test in docker, you have to close the VSCode, +```sh +rm -rf .metals .bloop project/.bloop +``` +and then restart VSCode and click 'Import build' again in the Metals popup. This takes around 35 seconds. + +This happens because the containers use the project directory, including its internal tooling directories via mounted volume and the IDE and Debian tests keep overwriting these settings. + + +## Running Tests +Tests are untagged by default or tagged as expensive. +```scala +import test.needsDBs +test("PostgreSQL responds".tag(needsDBs)) { ??? } +``` + +```bash +# Run all tests (both cheap and expensive) +sbt test +# Run only expensive tests +sbt "testOnly -- --include-tags=Expensive" +# Run only cheap tests +sbt "testOnly -- --exclude-tags=Expensive" +``` + +## Containerization + +We provide a `dev.sh` script to manage the development environment using Docker Compose. +```bash +# Start all required databases +./dev.sh db-start +# Stop all databases +./dev.sh db-stop +# Run all tests +./dev.sh test +``` +For convenience, bash completion is provided for the `dev.sh` script. To enable it: +```bash +# Add this to your ~/.bashrc or ~/.bash_profile +source /path/to/project/dev.sh.completion +``` +After enabling completion, you can use Tab to autocomplete `dev.sh` commands: +```bash +./dev.sh +# Shows: db-start db-stop test +``` +Test results from Docker are automatically saved to the `test-results` directory with timestamps. + +The containerized environment includes: +- PostgreSQL (port 5433) +- MySQL (port 3307) +- MariaDB (port 3308) +- SQLite, DuckDB, H2 (in-memory from the main container) + +## DataGrip IDE + +`.idea/dataSources.xml` +```xml + + + + + mysql.8 + true + com.mysql.cj.jdbc.Driver + jdbc:mysql://localhost:3307 + $ProjectFileDir$ + + + h2.unified + true + org.h2.Driver + jdbc:h2:mem:default + $ProjectFileDir$ + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://localhost:5433/testdb + $ProjectFileDir$ + + + mariadb + true + org.mariadb.jdbc.Driver + jdbc:mariadb://localhost:3308 + $ProjectFileDir$ + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite::memory: + $ProjectFileDir$ + + + duckdb + true + org.duckdb.DuckDBDriver + jdbc:duckdb: + $ProjectFileDir$ + + + +``` + +SQLite, H2, DuckDB are in-memory, no auth. Postgres, MySQL, MariaDB are localhost testuser:testpass. Watch out for ports! +- PostgreSQL: 5433 (and not 5432) +- MySQL: 3307 (and not 3306) +- MariaDB: 3308 (and not 3306) + +This is what a correctly configured DataGrip looks like: +![Correctly Configured DataGrip](./correctly-configured-DataGrip.png) + +## Windows 11 tooling + +Paste the `prepare.ps1` script into a priviledged PowerShell session once, in a Windows 11 virtual machine. +Then run the tests from some public branch by invoking `download_and_sbt_test.ps1` from an unpriviledged PowerShell session. + +For now there is no further automation, as we expect to test on Windows only just before the release. + +`prepare.ps1` +```powershell +# Install Java 23 +$url = "https://download.oracle.com/java/23/latest/jdk-23_windows-x64_bin.msi" +$output = "$env:TEMP\jdk-23_windows-x64_bin.msi" +Invoke-WebRequest -Uri $url -OutFile $output +Start-Process msiexec.exe -Wait -ArgumentList "/i $output /qn" +Remove-Item $output + +# Install chocolatey +Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + +# Instal general dependencies +## This sometimes works and then you cannot find the executables in PATH, it's a known issue, I do not know what causes it +choco install -y sbt +``` + +`download_and_sbt_test.ps1` +```powershell +# download the repository and enter it +$url = "https://github.com/aherlihy/tyql/archive/refs/heads/main.zip" +$downloadPath = "$env:TEMP\downloaded.zip" +$extractPath = "$env:TEMP\extracted" +Invoke-WebRequest -Uri $url -OutFile $downloadPath +if (-not (Test-Path $downloadPath)) { + throw "Download failed: File not found at $downloadPath" +} +Expand-Archive -Path $downloadPath -DestinationPath $extractPath -Force +if (-not (Test-Path $extractPath)) { + throw "Extraction failed: Directory not found at $extractPath" +} +$extractedDir = Get-ChildItem -Path $extractPath -Directory | Select-Object -First 1 +if (-not $extractedDir) { + throw "No directory found in the extracted contents" +} +Set-Location -Path $extractedDir.FullName + +sbt test + +# clean up +Set-Location -Path $env:TEMP +Remove-Item -Path $downloadPath -Force +Remove-Item -Path $extractPath -Recurse -Force +``` From 68e6d4ec6f9bab0d0fedea4d4a0a6c558be114b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Thu, 21 Nov 2024 17:15:24 +0100 Subject: [PATCH 058/106] scala version = 3.5.2 --- build.sbt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 0f6e4dc..fe23cf8 100644 --- a/build.sbt +++ b/build.sbt @@ -2,10 +2,12 @@ ThisBuild / version := "0.1.0-SNAPSHOT" inThisBuild(Seq( organization := "ch.epfl.lamp", - scalaVersion := "3.5.1-RC1", + scalaVersion := "3.5.2", version := "0.0.1", libraryDependencies ++= Seq( + // TODO do we still need this? https://github.com/scalameta/munit/issues/791 "org.scalameta" %% "munit" % "1.0.0+24-ee555b1d-SNAPSHOT" % Test, + // TODO later remove the dependency on all these drivers, they're large "org.postgresql" % "postgresql" % "42.7.4", "mysql" % "mysql-connector-java" % "8.0.33", "org.mariadb.jdbc" % "mariadb-java-client" % "3.5.0", From 4b5662963f86daf6ed7d85f5d9061610a8393aea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Thu, 21 Nov 2024 17:49:42 +0100 Subject: [PATCH 059/106] remove unused code --- src/test/scala/test/integration/DBsAreLive.scala | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/test/scala/test/integration/DBsAreLive.scala b/src/test/scala/test/integration/DBsAreLive.scala index 6f50cf2..44f1fa1 100644 --- a/src/test/scala/test/integration/DBsAreLive.scala +++ b/src/test/scala/test/integration/DBsAreLive.scala @@ -7,16 +7,6 @@ import java.sql.{Connection, DriverManager} import test.withDB class DBsAreLive extends FunSuite { - def withConnection[A](url: String, user: String = "", password: String = "")(f: Connection => A): A = { - var conn: Connection = null - try { - conn = DriverManager.getConnection(url, user, password) - f(conn) - } finally { - if (conn != null) conn.close() - } - } - test("PostgreSQL responds".tag(needsDBs)) { withDB.postgres { conn => val stmt = conn.createStatement() From 4b2de750f9bec10224549ec0f2eb3523075c6934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Thu, 21 Nov 2024 17:50:42 +0100 Subject: [PATCH 060/106] fix the postgresql driver registration issue --- documentation/development.md | 8 -------- src/test/scala/test/InvokeDBs.scala | 6 ++++++ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/documentation/development.md b/documentation/development.md index bb89e30..c1dd037 100644 --- a/documentation/development.md +++ b/documentation/development.md @@ -1,14 +1,6 @@ # Developer Documentation ## Current tooling problems -#### Missing Postgres driver (not urgent) -All works perfectly inside the containers. But when the DBs are up and I invoke `sbt run test` directly from my laptop, the first attempt to connect to Postgres ends with this: -``` -==> X test.integration.booleans.BooleanTests.boolean encoding 0.014s java.sql.SQLException: No suitable driver found for jdbc:postgresql://localhost:5433/testdb - at java.sql.DriverManager.getConnection(DriverManager.java:708) - at java.sql.DriverManager.getConnection(DriverManager.java:230) -``` - #### Containers break VSCode's integration (somewhat urgent) When you use VSCode with Metals, there are directories `.metals`, `.bloop`, `project/.bloop` which are being used. Of them the bloop directories are also used by the containerized tests. So after you run test in docker, you have to close the VSCode, ```sh diff --git a/src/test/scala/test/InvokeDBs.scala b/src/test/scala/test/InvokeDBs.scala index fc0b79e..12d66b4 100644 --- a/src/test/scala/test/InvokeDBs.scala +++ b/src/test/scala/test/InvokeDBs.scala @@ -27,6 +27,9 @@ private def withConnectionNoImplicits[A](url: String, user: String = "", passwor object withDB: def postgres[A](f: Connection => Dialect ?=> A): A = { + // XXX postgres driver needs to be poked first + // https://github.com/pgjdbc/pgjdbc/pull/772 + org.postgresql.Driver.isRegistered() withConnection( "jdbc:postgresql://localhost:5433/testdb", "testuser", @@ -89,6 +92,9 @@ object withDB: object withDBNoImplicits: def postgres[A](f: Connection => A): A = { + // XXX postgres driver needs to be poked first + // https://github.com/pgjdbc/pgjdbc/pull/772 + org.postgresql.Driver.isRegistered() withConnectionNoImplicits( "jdbc:postgresql://localhost:5433/testdb", "testuser", From d8f36545d9471d24f7c1d2f33b0b3a3c18957a9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Thu, 21 Nov 2024 18:00:55 +0100 Subject: [PATCH 061/106] remove unused import --- src/test/scala/test/TestSingeExpr.scala | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/scala/test/TestSingeExpr.scala b/src/test/scala/test/TestSingeExpr.scala index 7cef5ad..281f075 100644 --- a/src/test/scala/test/TestSingeExpr.scala +++ b/src/test/scala/test/TestSingeExpr.scala @@ -4,7 +4,6 @@ import munit.FunSuite import test.withDBNoImplicits import java.sql.{Connection, Statement, ResultSet} import tyql.{Dialect, Table, Expr} -import tyql.Subset.a import tyql.{NonScalarExpr, ResultTag} From 6d753e562d083245d221e5b197837529bccb9218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Thu, 21 Nov 2024 18:06:45 +0100 Subject: [PATCH 062/106] add lit(), True, False --- src/main/scala/tyql/expr/Expr.scala | 7 +++++++ src/test/scala/test/integration/booleans.scala | 5 +++-- src/test/scala/test/integration/precedence.scala | 10 +++++----- src/test/scala/test/integration/random.scala | 6 +++--- src/test/scala/test/integration/strings.scala | 14 +++++++------- 5 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index 127703e..f3f9806 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -288,3 +288,10 @@ object Expr: // given [A <: AnyNamedTuple : IsTupleOfExpr](using ResultTag[NamedTuple.Map[A, StripExpr]]): Conversion[A, Expr.Project[A]] = Expr.Project(_) end Expr + +// TODO where should this be? +def lit(x: Int): Expr[Int, NonScalarExpr] = Expr.IntLit(x) +def lit(x: Double): Expr[Double, NonScalarExpr] = Expr.DoubleLit(x) +def lit(x: String): Expr[String, NonScalarExpr] = Expr.StringLit(x) +def True = Expr.BooleanLit(true) +def False = Expr.BooleanLit(false) diff --git a/src/test/scala/test/integration/booleans.scala b/src/test/scala/test/integration/booleans.scala index ad73c17..24cd776 100644 --- a/src/test/scala/test/integration/booleans.scala +++ b/src/test/scala/test/integration/booleans.scala @@ -5,9 +5,10 @@ import test.{withDBNoImplicits, withDB, checkExpr, checkExprDialect} import java.sql.{Connection, Statement, ResultSet} import tyql.{Dialect, Table, Expr} +private val t = tyql.True +private val f = tyql.False + class BooleanTests extends FunSuite { - val t = Expr.BooleanLit(true) - val f = Expr.BooleanLit(false) def expect(expected: Boolean)(rs: ResultSet) = assertEquals(rs.getBoolean(1), expected) test("boolean encoding") { diff --git a/src/test/scala/test/integration/precedence.scala b/src/test/scala/test/integration/precedence.scala index 606bf4d..edbb340 100644 --- a/src/test/scala/test/integration/precedence.scala +++ b/src/test/scala/test/integration/precedence.scala @@ -9,11 +9,11 @@ class PrecedenceTests extends FunSuite { def expectB(expected: Boolean)(rs: ResultSet) = assertEquals(rs.getBoolean(1), expected) def expectI(expected: Int)(rs: ResultSet) = assertEquals(rs.getInt(1), expected) - val t = Expr.BooleanLit(true) - val f = Expr.BooleanLit(false) - val i1 = Expr.IntLit(1) - val i2 = Expr.IntLit(2) - val i3 = Expr.IntLit(3) + val t = tyql.True + val f = tyql.False + val i1 = tyql.lit(1) + val i2 = tyql.lit(2) + val i3 = tyql.lit(3) test("boolean precedence -- AND over OR") { checkExprDialect[Boolean](t || f && f, expectB(true))(withDB.all) diff --git a/src/test/scala/test/integration/random.scala b/src/test/scala/test/integration/random.scala index 1618f1e..855618d 100644 --- a/src/test/scala/test/integration/random.scala +++ b/src/test/scala/test/integration/random.scala @@ -134,7 +134,7 @@ class RandomTests extends FunSuite { } { import Dialect.mysql.given - checkExprDialect[Int](Expr.randomInt(0, 2), checkValue)(withDB.mysql) + checkExprDialect[Int](Expr.randomInt(tyql.lit(0), 2), checkValue)(withDB.mysql) checkExprDialect[Int](Expr.randomInt(44, 44), checkInclusion)(withDB.mysql) } { @@ -145,7 +145,7 @@ class RandomTests extends FunSuite { { import Dialect.duckdb.given checkExprDialect[Int](Expr.randomInt(0, 2), checkValue)(withDB.duckdb) - checkExprDialect[Int](Expr.randomInt(44, 44), checkInclusion)(withDB.duckdb) + checkExprDialect[Int](Expr.randomInt(44, tyql.lit(44)), checkInclusion)(withDB.duckdb) } { import Dialect.h2.given @@ -169,7 +169,7 @@ class RandomTests extends FunSuite { { import tyql.DialectFeature.RandomIntegerInInclusiveRange given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} - q = Expr.randomInt(101, 101) + q = Expr.randomInt(tyql.lit(101), 101) } { diff --git a/src/test/scala/test/integration/strings.scala b/src/test/scala/test/integration/strings.scala index 75a5d9f..a282c43 100644 --- a/src/test/scala/test/integration/strings.scala +++ b/src/test/scala/test/integration/strings.scala @@ -11,9 +11,9 @@ class StringTests extends FunSuite { // XXX the expression is defined under ANSI SQL dialect, but toSQLQuery is run against a specific dialect and it works! assertEquals(summon[Dialect].name(), "ANSI SQL Dialect") - checkExprDialect[Int](Expr.StringLit("ałajć").length, checkValue(5))(withDB.all) - checkExprDialect[Int](Expr.StringLit("ałajć").charLength, checkValue(5))(withDB.all) - checkExprDialect[Int](Expr.StringLit("ałajć").byteLength, checkValue(7))(withDB.all) + checkExprDialect[Int](tyql.lit("ałajć").length, checkValue(5))(withDB.all) + checkExprDialect[Int](tyql.lit("ałajć").charLength, checkValue(5))(withDB.all) + checkExprDialect[Int](tyql.lit("ałajć").byteLength, checkValue(7))(withDB.all) } test("upper and lower work also with unicode") { @@ -21,12 +21,12 @@ class StringTests extends FunSuite { for (r <- Seq(withDBNoImplicits.postgres[Unit], withDBNoImplicits.mariadb[Unit], withDBNoImplicits.mysql[Unit], withDBNoImplicits.h2[Unit], withDBNoImplicits.duckdb[Unit])) { - checkExpr[String](Expr.StringLit("aŁaJć").toUpperCase, checkValue("AŁAJĆ"))(r) - checkExpr[String](Expr.StringLit("aŁaJć").toLowerCase, checkValue("ałajć"))(r) + checkExpr[String](tyql.lit("aŁaJć").toUpperCase, checkValue("AŁAJĆ"))(r) + checkExpr[String](tyql.lit("aŁaJć").toLowerCase, checkValue("ałajć"))(r) } // SQLite does not support unicode case folding by default unless compiled with ICU support - checkExpr[String](Expr.StringLit("A bRoWn fOX").toUpperCase, checkValue("A BROWN FOX"))(withDBNoImplicits.sqlite) - checkExpr[String](Expr.StringLit("A bRoWn fOX").toLowerCase, checkValue("a brown fox"))(withDBNoImplicits.sqlite) + checkExpr[String](tyql.lit("A bRoWn fOX").toUpperCase, checkValue("A BROWN FOX"))(withDBNoImplicits.sqlite) + checkExpr[String](tyql.lit("A bRoWn fOX").toLowerCase, checkValue("a brown fox"))(withDBNoImplicits.sqlite) } } From 1ca7b314377980a79bbc39f6444525e64fc35b8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Thu, 21 Nov 2024 19:08:22 +0100 Subject: [PATCH 063/106] CASE statements --- src/main/scala/tyql/expr/Expr.scala | 40 +++++++++ src/main/scala/tyql/ir/QueryIRNode.scala | 15 ++++ src/main/scala/tyql/ir/QueryIRTree.scala | 2 + src/test/scala/test/integration/cases.scala | 95 +++++++++++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 src/test/scala/test/integration/cases.scala diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index f3f9806..acea11c 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -44,6 +44,21 @@ trait Expr[Result, Shape <: ExprShape](using val tag: ResultTag[Result]) extends @targetName("neqScalar") def != (other: Expr[?, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.Ne[Shape, ScalarExpr](this, other) + def cases[DestinationT: ResultTag, SV <: ExprShape](cases: (Expr[Result, Shape] | ElseToken, Expr[DestinationT, SV])*): Expr[DestinationT, SV] = + type FromT = Result + var mainCases: collection.mutable.ArrayBuffer[(Expr[FromT, Shape], Expr[DestinationT, SV])] = collection.mutable.ArrayBuffer.empty + var elseCase: Option[Expr[DestinationT, SV]] = None + if cases.isEmpty then assert(false, "cases must not be empty") // XXX maybe enforce via type system? + for (((condition, value), index) <- cases.zipWithIndex) { + condition match + case _: ElseToken => + assert(index == cases.size - 1, "true condition must be last") + elseCase = Some(value) + case _: Expr[?, ?] => + mainCases += ((condition.asInstanceOf[Expr[FromT, Shape]], value)) + } + Expr.SimpleCase(this, mainCases.toList, elseCase) + object Expr: /** Sample extension methods for individual types */ extension [S1 <: ExprShape](x: Expr[Int, S1]) @@ -127,6 +142,25 @@ object Expr: @targetName("stringCnt") def count(x: Expr[String, ?]): AggregationExpr[Int] = AggregationExpr.Count(x) + // TODO aren't these types too restrictive? + def cases[T: ResultTag, SC <: ExprShape, SV <: ExprShape](cases: (Expr[Boolean, SC] | true | ElseToken, Expr[T, SV])*): Expr[T, SV] = + var mainCases: collection.mutable.ArrayBuffer[(Expr[Boolean, SC], Expr[T, SV])] = collection.mutable.ArrayBuffer.empty + var elseCase: Option[Expr[T, SV]] = None + if cases.isEmpty then assert(false, "cases must not be empty") // XXX maybe enforce via type system? + for (((condition, value), index) <- cases.zipWithIndex) { + condition match + case _: ElseToken => + assert(index == cases.size - 1, "true condition must be last") + elseCase = Some(value) + case true => + assert(index == cases.size - 1, "true condition must be last") + elseCase = Some(value) + case false => assert(false, "what do you mean, false?") + case _: Expr[?, ?] => + mainCases += ((condition.asInstanceOf[Expr[Boolean, SC]], value)) + } + SearchedCase(mainCases.toList, elseCase) + // Note: All field names of constructors in the query language are prefixed with `$` // so that we don't accidentally pick a field name of a constructor class where we want // a name in the domain model instead. @@ -213,6 +247,10 @@ object Expr: */ case class Fun[A, B, S <: ExprShape]($param: Ref[A, S], $body: B) + // TODO aren't these types too restrictive? + case class SearchedCase[T, SC <: ExprShape, SV <: ExprShape]($cases: List[(Expr[Boolean, SC], Expr[T, SV])], $else: Option[Expr[T, SV]])(using ResultTag[T]) extends Expr[T, SV] + case class SimpleCase[TE, TR, SE <: ExprShape, SR <: ExprShape]($expr: Expr[TE, SE], $cases: List[(Expr[TE, SE], Expr[TR, SR])], $else: Option[Expr[TR, SR]])(using ResultTag[TE], ResultTag[TR]) extends Expr[TR, SR] + /** Literals are type-specific, tailored to the types that the DB supports */ case class IntLit($value: Int) extends Expr[Int, NonScalarExpr] /** Scala values can be lifted into literals by conversions */ @@ -295,3 +333,5 @@ def lit(x: Double): Expr[Double, NonScalarExpr] = Expr.DoubleLit(x) def lit(x: String): Expr[String, NonScalarExpr] = Expr.StringLit(x) def True = Expr.BooleanLit(true) def False = Expr.BooleanLit(false) +private case class ElseToken() +val Else = new ElseToken() diff --git a/src/main/scala/tyql/ir/QueryIRNode.scala b/src/main/scala/tyql/ir/QueryIRNode.scala index c3f9029..bf3b103 100644 --- a/src/main/scala/tyql/ir/QueryIRNode.scala +++ b/src/main/scala/tyql/ir/QueryIRNode.scala @@ -70,6 +70,21 @@ case class FunctionCallOp(name: String, children: Seq[QueryIRNode], ast: Expr[?, override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = s"$name(" + children.map(_.toSQLString()).mkString(", ") + ")" // TODO does this need ()s sometimes? +case class SearchedCaseOp(whenClauses: Seq[(QueryIRNode, QueryIRNode)], elseClause: Option[QueryIRNode], ast: Expr[?, ?]) extends QueryIRNode: + override val precedence = Precedence.Literal + override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = + val whenStr = whenClauses.map { case (cond, res) => s"WHEN ${cond.toSQLString()} THEN ${res.toSQLString()}" }.mkString(" ") + val elseStr = elseClause.map(e => s" ELSE ${e.toSQLString()}").getOrElse("") + s"CASE $whenStr$elseStr END" + +case class SimpleCaseOp(expr: QueryIRNode, whenClauses: Seq[(QueryIRNode, QueryIRNode)], elseClause: Option[QueryIRNode], ast: Expr[?, ?]) extends QueryIRNode: + override val precedence = Precedence.Literal + override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = + val exprStr = expr.toSQLString() + val whenStr = whenClauses.map { case (cond, res) => s"WHEN ${cond.toSQLString()} THEN ${res.toSQLString()}" }.mkString(" ") + val elseStr = elseClause.map(e => s" ELSE ${e.toSQLString()}").getOrElse("") + s"CASE $exprStr $whenStr$elseStr END" + case class RawSQLInsertOp(sql: String, replacements: Map[String, QueryIRNode], override val precedence: Int, ast: Expr[?, ?]) extends QueryIRNode: override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = replacements.foldLeft(sql) { case (acc, (k, v)) => acc.replace(k, v.toSQLString()) } diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index aa8c865..d9dd392 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -397,6 +397,8 @@ object QueryIRTree: case l: Expr.IntLit => Literal(s"${l.$value}", l) case l: Expr.StringLit => Literal(d.quoteStringLiteral(l.$value, insideLikePattern=false), l) // TODO fix this for LIKE patterns case l: Expr.BooleanLit => Literal(d.quoteBooleanLiteral(l.$value), l) + case c: Expr.SearchedCase[?, ?, ?] => SearchedCaseOp(c.$cases.map(w => (generateExpr(w._1, symbols), generateExpr(w._2, symbols))), c.$else.map(generateExpr(_, symbols)), c) + case c: Expr.SimpleCase[?, ?, ?, ?] => SimpleCaseOp(generateExpr(c.$expr, symbols), c.$cases.map(w => (generateExpr(w._1, symbols), generateExpr(w._2, symbols))), c.$else.map(generateExpr(_, symbols)), c) case l: Expr.Lower[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"LOWER($o)", l) case l: Expr.Upper[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"UPPER($o)", l) case l: Expr.StringCharLength[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"${d.stringLengthByCharacters}($o)", l) diff --git a/src/test/scala/test/integration/cases.scala b/src/test/scala/test/integration/cases.scala new file mode 100644 index 0000000..236aea3 --- /dev/null +++ b/src/test/scala/test/integration/cases.scala @@ -0,0 +1,95 @@ +package test.integration.cases + +import munit.FunSuite +import test.{withDB, checkExprDialect} +import tyql.Expr.cases +import tyql.{lit, True, False, Else} +import java.sql.ResultSet +import tyql.NonScalarExpr + +class CaseTests extends FunSuite { + def expectI(expected: Int)(rs: ResultSet) = assertEquals(rs.getInt(1), expected) + def expectB(expected: Boolean)(rs: ResultSet) = assertEquals(rs.getBoolean(1), expected) + def expectS(expected: String)(rs: ResultSet) = assertEquals(rs.getString(1), expected) + + def usesElse(s: String) = assert(s.toLowerCase().contains(" else ")) + def noElse(s: String) = assert(! s.toLowerCase().contains(" else ")) + + test("searched CASE") { + checkExprDialect[Int]( + cases( + True -> lit(10)), + expectI(10), noElse)(withDB.all) + + checkExprDialect[String]( + cases( + (lit(20) < lit(10)) -> lit("lt"), + (lit(20) == lit(10)) -> lit("eq"), + (lit(20) > lit(10)) -> lit("gt")), + expectS("gt"), noElse)(withDB.all) + + checkExprDialect[String]( + cases( + (lit(15) < lit(15)) -> lit("lt"), + (lit(15) == lit(15)) -> lit("eq"), + (lit(15) > lit(15)) -> lit("gt")), + expectS("eq"), noElse)(withDB.all) + + checkExprDialect[String]( + cases( + (lit(15) < lit(100)) -> lit("lt"), + (lit(15) == lit(100)) -> lit("eq"), + (lit(15) > lit(100)) -> lit("gt")), + expectS("lt"), noElse)(withDB.all) + + checkExprDialect[String]( + cases( + (lit(105) < lit(100)) -> lit("lt"), + (lit(15) == lit(100)) -> lit("eq"), + (lit(15) > lit(100)) -> lit("gt"), + true -> lit("neither")), + expectS("neither"), usesElse)(withDB.all) + + checkExprDialect[Int]( + cases( + False -> lit(10), + true -> lit(20)), + expectI(20), usesElse)(withDB.all) + + checkExprDialect[Int]( + cases( + False -> lit(10), + Else -> lit(20)), + expectI(20), usesElse)(withDB.all) + } + + test("simple CASE") { + checkExprDialect[Int]( + lit(10).cases( + lit(9) -> lit(90), + lit(10) -> lit(100), + lit(11) -> lit(110), + ), + expectI(100), noElse)(withDB.all) + + checkExprDialect[Boolean]( + lit("abba").cases( + lit("aaaaa") -> False, + lit("bbbfsfad") -> False, + lit("abba") -> True, + Else -> False + ), + expectB(true), usesElse)(withDB.all) + } + + test("simple CASE does not allow LIKE patterns") { + checkExprDialect[Boolean]( + lit("abba").cases( + lit("a%") -> False, + lit("%b%") -> False, + lit("a__a") -> False, + Else -> True + ), + expectB(true), usesElse)(withDB.all) + } +} From 97a86f9968c9ce26e2f9326e96acf3f7ed0960eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Thu, 21 Nov 2024 19:17:45 +0100 Subject: [PATCH 064/106] enforce arg # for CASE with type system --- src/main/scala/tyql/expr/Expr.scala | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index acea11c..1b978a4 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -44,15 +44,15 @@ trait Expr[Result, Shape <: ExprShape](using val tag: ResultTag[Result]) extends @targetName("neqScalar") def != (other: Expr[?, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.Ne[Shape, ScalarExpr](this, other) - def cases[DestinationT: ResultTag, SV <: ExprShape](cases: (Expr[Result, Shape] | ElseToken, Expr[DestinationT, SV])*): Expr[DestinationT, SV] = + def cases[DestinationT: ResultTag, SV <: ExprShape](firstCase: (Expr[Result, Shape] | ElseToken, Expr[DestinationT, SV]), restOfCases: (Expr[Result, Shape] | ElseToken, Expr[DestinationT, SV])*): Expr[DestinationT, SV] = type FromT = Result var mainCases: collection.mutable.ArrayBuffer[(Expr[FromT, Shape], Expr[DestinationT, SV])] = collection.mutable.ArrayBuffer.empty var elseCase: Option[Expr[DestinationT, SV]] = None - if cases.isEmpty then assert(false, "cases must not be empty") // XXX maybe enforce via type system? + val cases = Seq(firstCase) ++ restOfCases for (((condition, value), index) <- cases.zipWithIndex) { condition match case _: ElseToken => - assert(index == cases.size - 1, "true condition must be last") + assert(index == cases.size - 1, "The default condition must be last") elseCase = Some(value) case _: Expr[?, ?] => mainCases += ((condition.asInstanceOf[Expr[FromT, Shape]], value)) @@ -143,17 +143,17 @@ object Expr: def count(x: Expr[String, ?]): AggregationExpr[Int] = AggregationExpr.Count(x) // TODO aren't these types too restrictive? - def cases[T: ResultTag, SC <: ExprShape, SV <: ExprShape](cases: (Expr[Boolean, SC] | true | ElseToken, Expr[T, SV])*): Expr[T, SV] = + def cases[T: ResultTag, SC <: ExprShape, SV <: ExprShape](firstCase: (Expr[Boolean, SC] | true | ElseToken, Expr[T, SV]), restOfCases: (Expr[Boolean, SC] | true | ElseToken, Expr[T, SV])*): Expr[T, SV] = var mainCases: collection.mutable.ArrayBuffer[(Expr[Boolean, SC], Expr[T, SV])] = collection.mutable.ArrayBuffer.empty var elseCase: Option[Expr[T, SV]] = None - if cases.isEmpty then assert(false, "cases must not be empty") // XXX maybe enforce via type system? + val cases = Seq(firstCase) ++ restOfCases for (((condition, value), index) <- cases.zipWithIndex) { condition match case _: ElseToken => - assert(index == cases.size - 1, "true condition must be last") + assert(index == cases.size - 1, "The default condition must be last") elseCase = Some(value) case true => - assert(index == cases.size - 1, "true condition must be last") + assert(index == cases.size - 1, "The default condition must be last") elseCase = Some(value) case false => assert(false, "what do you mean, false?") case _: Expr[?, ?] => From 98b560bd4db42b6d0ccec9fe29920358a09c4984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Thu, 21 Nov 2024 21:04:02 +0100 Subject: [PATCH 065/106] basic string operations --- src/main/scala/tyql/expr/Expr.scala | 39 +++- src/main/scala/tyql/ir/QueryIRTree.scala | 22 ++- src/test/scala/test/integration/strings.scala | 168 +++++++++++++++++- 3 files changed, 217 insertions(+), 12 deletions(-) diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index 1b978a4..74eeaf9 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -116,6 +116,33 @@ object Expr: def charLength: Expr[Int, S1] = Expr.StringCharLength(x) def length: Expr[Int, S1] = charLength def byteLength: Expr[Int, S1] = Expr.StringByteLength(x) + def stripLeading: Expr[String, S1] = Expr.LTrim(x) // Java naming + def stripTrailing: Expr[String, S1] = Expr.RTrim(x) // Java naming + def strip: Expr[String, S1] = Expr.Trim(x) // Java naming + def ltrim: Expr[String, S1] = Expr.LTrim(x) // SQL naming + def rtrim: Expr[String, S1] = Expr.RTrim(x) // SQL naming + def trim: Expr[String, S1] = Expr.Trim(x) // SQL naming + def replace[S2 <: ExprShape](from: Expr[String, S2], to: Expr[String, S2]): Expr[String, CalculatedShape[S1, S2]] = Expr.StrReplace(x, from, to) + // TODO maybe add assertions that len should be >= 0 and from >= 1 if we know them? + // SQL semantics (1-based indexing, start+length) + def substr[S2 <: ExprShape](from: Expr[Int, S2], len: Expr[Int, S2] = null): Expr[String, CalculatedShape[S1, S2]] = Expr.Substring(x, from, Option.fromNullable(len)) + // Java semantics (0-based indexing, start+afterLast) + def substring[S2 <: ExprShape](start: Expr[Int, S2], afterLast: Expr[Int, S2] = null): Expr[String, CalculatedShape[S1, S2]] = + if afterLast != null then + substr(Expr.Plus(Expr.IntLit(1), start).asInstanceOf[Expr[Int, S2]], Expr.Minus(afterLast, start).asInstanceOf[Expr[Int, S2]]) // XXX how to avoid this cast + else + substr(Expr.Plus(Expr.IntLit(1), start).asInstanceOf[Expr[Int, S2]], null) // XXX how to avoid this cast + def like[S2 <: ExprShape](pattern: Expr[String, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = Expr.StrLike(x, pattern) + def `+`[S2 <: ExprShape](y: Expr[String, S2]): Expr[String, CalculatedShape[S1, S2]] = Expr.StrConcat(x, Seq(y)) + + def concat[S <: ExprShape](strs: Seq[Expr[String, S]]): Expr[String, S] = + assert(strs.nonEmpty, "concat requires at least one argument") + StrConcatUniform(strs.head, strs.tail) + + // TODO XXX this cannot be named concat since then Scala will never resolve it, it will always try for the first version without the sep parameter. + def concatWith[S <: ExprShape, SS <: ExprShape](strs: Seq[Expr[String, S]], sep: Expr[String, SS]): Expr[String, CalculatedShape[S, SS]] = + assert(strs.nonEmpty, "concatWith requires at least one argument") + StrConcatSeparator(sep, strs.head, strs.tail) extension [A](x: Expr[List[A], NonScalarExpr])(using ResultTag[List[A]]) def prepend(elem: Expr[A, NonScalarExpr]): Expr[List[A], NonScalarExpr] = ListPrepend(elem, x) @@ -177,6 +204,7 @@ object Expr: case class FunctionCall1[A1, R, S1 <: ExprShape](name: String, $a1: Expr[A1, S1])(using ResultTag[R]) extends Expr[R, S1] case class FunctionCall2[A1, A2, R, S1 <: ExprShape, S2 <: ExprShape](name: String, $a1: Expr[A1, S1], $a2: Expr[A2, S2])(using ResultTag[R]) extends Expr[R, CalculatedShape[S1, S2]] + // TODO think about it again case class RawSQLInsert[R](sql: String, replacements: Map[String, Expr[?, ?]] = Map.empty)(using ResultTag[R]) extends Expr[R, NonScalarExpr] // XXX TODO NonScalarExpr? case class Plus[S1 <: ExprShape, S2 <: ExprShape, T: Numeric]($x: Expr[T, S1], $y: Expr[T, S2])(using ResultTag[T]) extends Expr[T, CalculatedShape[S1, S2]] @@ -191,6 +219,15 @@ object Expr: case class Lower[S <: ExprShape]($x: Expr[String, S]) extends Expr[String, S] case class StringCharLength[S <: ExprShape]($x: Expr[String, S]) extends Expr[Int, S] case class StringByteLength[S <: ExprShape]($x: Expr[String, S]) extends Expr[Int, S] + case class Trim[S <: ExprShape]($x: Expr[String, S]) extends Expr[String, S] + case class LTrim[S <: ExprShape]($x: Expr[String, S]) extends Expr[String, S] + case class RTrim[S <: ExprShape]($x: Expr[String, S]) extends Expr[String, S] + case class StrReplace[S <: ExprShape, S2 <: ExprShape]($s: Expr[String, S], $from: Expr[String, S2], $to: Expr[String, S2]) extends Expr[String, CalculatedShape[S, S2]] + case class Substring[S <: ExprShape, S2 <: ExprShape]($s: Expr[String, S], $from: Expr[Int, S2], $len: Option[Expr[Int, S2]]) extends Expr[String, CalculatedShape[S, S2]] + case class StrLike[S <: ExprShape, S2 <: ExprShape]($s: Expr[String, S], $pattern: Expr[String, S2]) extends Expr[Boolean, CalculatedShape[S, S2]] // NonScalar like StringLit + case class StrConcat[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[String, S1], $xs: Seq[Expr[String, S2]]) extends Expr[String, CalculatedShape[S1, S2]] // First one has a different shape so you can use it as an opertor between two arguments that have different shapes + case class StrConcatUniform[S1 <: ExprShape]($x: Expr[String, S1], $xs: Seq[Expr[String, S1]]) extends Expr[String, S1] + case class StrConcatSeparator[S1 <: ExprShape, S3 <: ExprShape]($sep: Expr[String, S3], $x: Expr[String, S1], $xs: Seq[Expr[String, S1]]) extends Expr[String, CalculatedShape[S1, S3]] case class RandomUUID() extends Expr[String, NonScalarExpr] // XXX NonScalarExpr? case class RandomFloat() extends Expr[Double, NonScalarExpr] // XXX NonScalarExpr? @@ -257,7 +294,7 @@ object Expr: given Conversion[Int, IntLit] = IntLit(_) // XXX maybe only from literals with FromDigits? - case class StringLit($value: String) extends Expr[String, NonScalarExpr] + case class StringLit($value: String) extends Expr[String, NonScalarExpr] // TODO XXX why is this nonscalar? given Conversion[String, StringLit] = StringLit(_) case class DoubleLit($value: Double) extends Expr[Double, NonScalarExpr] diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index d9dd392..d4b90c5 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -401,6 +401,22 @@ object QueryIRTree: case c: Expr.SimpleCase[?, ?, ?, ?] => SimpleCaseOp(generateExpr(c.$expr, symbols), c.$cases.map(w => (generateExpr(w._1, symbols), generateExpr(w._2, symbols))), c.$else.map(generateExpr(_, symbols)), c) case l: Expr.Lower[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"LOWER($o)", l) case l: Expr.Upper[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"UPPER($o)", l) + case l: Expr.Trim[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"TRIM($o)", l) + case l: Expr.LTrim[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"LTRIM($o)", l) + case l: Expr.RTrim[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"RTRIM($o)", l) + case l: Expr.StrReplace[?, ?] => FunctionCallOp("REPLACE", Seq(generateExpr(l.$s, symbols), generateExpr(l.$from, symbols), generateExpr(l.$to, symbols)), l) + case l: Expr.StrLike[?, ?] => + l.$pattern match + case Expr.StringLit(pattern) => BinExprOp(generateExpr(l.$s, symbols), Literal(d.quoteStringLiteral(pattern, insideLikePattern=true), l.$pattern), (l, r) => s"$l LIKE $r", Precedence.Comparison, l) + case _ => assert(false, "LIKE pattern must be a string literal") + case l: Expr.Substring[?, ?] => + if l.$len.isEmpty then + FunctionCallOp("SUBSTRING", Seq(generateExpr(l.$s, symbols), generateExpr(l.$from, symbols)), l) + else + FunctionCallOp("SUBSTRING", Seq(generateExpr(l.$s, symbols), generateExpr(l.$from, symbols), generateExpr(l.$len.get, symbols)), l) + case l: Expr.StrConcat[?, ?] => FunctionCallOp("CONCAT", (Seq(l.$x) ++ l.$xs).map(generateExpr(_, symbols)), l) + case l: Expr.StrConcatUniform[?] => FunctionCallOp("CONCAT", (Seq(l.$x) ++ l.$xs).map(generateExpr(_, symbols)), l) + case l: Expr.StrConcatSeparator[?, ?] => FunctionCallOp("CONCAT_WS", (Seq(l.$sep, l.$x) ++ l.$xs).map(generateExpr(_, symbols)), l) case l: Expr.StringCharLength[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"${d.stringLengthByCharacters}($o)", l) case l: Expr.StringByteLength[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => (d.stringLengthByBytes match @@ -410,9 +426,9 @@ object QueryIRTree: case a: AggregationExpr[?] => generateAggregation(a, symbols) case a: Aggregation[?, ?] => generateQuery(a, symbols).appendFlag(SelectFlags.ExprLevel) case list: Expr.ListExpr[?] => ListTypeExpr(list.$elements.map(generateExpr(_, symbols)), list) - case p: Expr.ListPrepend[?] => BinExprOp(generateExpr(p.$x, symbols), generateExpr(p.$list, symbols), (l, r) => s"list_prepend($l, $r)", Precedence.ListOps, p) - case p: Expr.ListAppend[?] => BinExprOp(generateExpr(p.$list, symbols), generateExpr(p.$x, symbols),(l, r) => s"list_append($l, $r)", Precedence.ListOps, p) - case p: Expr.ListContains[?] => BinExprOp(generateExpr(p.$list, symbols), generateExpr(p.$x, symbols),(l, r) => s"list_contains($l, $r)", Precedence.ListOps, p) + case p: Expr.ListPrepend[?] => BinExprOp(generateExpr(p.$x, symbols), generateExpr(p.$list, symbols), (l, r) => s"list_prepend($l, $r)", Precedence.Unary, p) + case p: Expr.ListAppend[?] => BinExprOp(generateExpr(p.$list, symbols), generateExpr(p.$x, symbols),(l, r) => s"list_append($l, $r)", Precedence.Unary, p) + case p: Expr.ListContains[?] => BinExprOp(generateExpr(p.$list, symbols), generateExpr(p.$x, symbols),(l, r) => s"list_contains($l, $r)", Precedence.Unary, p) case p: Expr.ListLength[?] => UnaryExprOp(generateExpr(p.$list, symbols), s => s"length($s)", p) case p: Expr.NonEmpty[?] => UnaryExprOp(generateQuery(p.$this, symbols).appendFlag(SelectFlags.Final), s => s"EXISTS ($s)", p) case p: Expr.IsEmpty[?] => UnaryExprOp(generateQuery(p.$this, symbols).appendFlag(SelectFlags.Final), s => s"NOT EXISTS ($s)", p) diff --git a/src/test/scala/test/integration/strings.scala b/src/test/scala/test/integration/strings.scala index a282c43..7046f70 100644 --- a/src/test/scala/test/integration/strings.scala +++ b/src/test/scala/test/integration/strings.scala @@ -3,7 +3,8 @@ package test.integration.strings import munit.FunSuite import test.{withDBNoImplicits, withDB, checkExpr, checkExprDialect} import java.sql.{Connection, Statement, ResultSet} -import tyql.{Dialect, Table, Expr} +import tyql.{Dialect, Table, Expr, lit} +import tyql.Dialect.{literal_percent, literal_underscore} class StringTests extends FunSuite { test("string length by characters and bytes, length is aliased to characterLength tests") { @@ -11,9 +12,10 @@ class StringTests extends FunSuite { // XXX the expression is defined under ANSI SQL dialect, but toSQLQuery is run against a specific dialect and it works! assertEquals(summon[Dialect].name(), "ANSI SQL Dialect") - checkExprDialect[Int](tyql.lit("ałajć").length, checkValue(5))(withDB.all) - checkExprDialect[Int](tyql.lit("ałajć").charLength, checkValue(5))(withDB.all) - checkExprDialect[Int](tyql.lit("ałajć").byteLength, checkValue(7))(withDB.all) + val s = lit("ałajć") + checkExprDialect[Int](s.length, checkValue(5))(withDB.all) + checkExprDialect[Int](s.charLength, checkValue(5))(withDB.all) + checkExprDialect[Int](s.byteLength, checkValue(7))(withDB.all) } test("upper and lower work also with unicode") { @@ -21,12 +23,162 @@ class StringTests extends FunSuite { for (r <- Seq(withDBNoImplicits.postgres[Unit], withDBNoImplicits.mariadb[Unit], withDBNoImplicits.mysql[Unit], withDBNoImplicits.h2[Unit], withDBNoImplicits.duckdb[Unit])) { - checkExpr[String](tyql.lit("aŁaJć").toUpperCase, checkValue("AŁAJĆ"))(r) - checkExpr[String](tyql.lit("aŁaJć").toLowerCase, checkValue("ałajć"))(r) + checkExpr[String](lit("aŁaJć").toUpperCase, checkValue("AŁAJĆ"))(r) + checkExpr[String](lit("aŁaJć").toLowerCase, checkValue("ałajć"))(r) } // SQLite does not support unicode case folding by default unless compiled with ICU support - checkExpr[String](tyql.lit("A bRoWn fOX").toUpperCase, checkValue("A BROWN FOX"))(withDBNoImplicits.sqlite) - checkExpr[String](tyql.lit("A bRoWn fOX").toLowerCase, checkValue("a brown fox"))(withDBNoImplicits.sqlite) + checkExpr[String](lit("A bRoWn fOX").toUpperCase, checkValue("A BROWN FOX"))(withDBNoImplicits.sqlite) + checkExpr[String](lit("A bRoWn fOX").toLowerCase, checkValue("a brown fox"))(withDBNoImplicits.sqlite) + } + + test("trim ltrim rtrim") { + checkExprDialect[String]( + lit(" a b c ").trim, + (rs: ResultSet) => assertEquals(rs.getString(1), "a b c") + )(withDB.all) + checkExprDialect[String]( + lit(" a b c ").ltrim, + (rs: ResultSet) => assertEquals(rs.getString(1), "a b c ") + )(withDB.all) + checkExprDialect[String]( + lit(" a b c ").rtrim, + (rs: ResultSet) => assertEquals(rs.getString(1), " a b c") + )(withDB.all) + checkExprDialect[String]( + lit(" a b c ").stripLeading, + (rs: ResultSet) => assertEquals(rs.getString(1), "a b c ") + )(withDB.all) + checkExprDialect[String]( + lit(" a b c ").stripTrailing, + (rs: ResultSet) => assertEquals(rs.getString(1), " a b c") + )(withDB.all) + } + + test("string replace") { + checkExprDialect[String]( + lit("aabbccaaa").replace(lit("aa"), lit("XX")), + (rs: ResultSet) => assertEquals(rs.getString(1), "XXbbccXXa") + )(withDB.all) + } + + test("substr and unicode") { + checkExprDialect[String]( + lit("ałajć").substr(lit(2), lit(1)), + (rs: ResultSet) => assertEquals(rs.getString(1), "ł") + )(withDB.all) + checkExprDialect[String]( + lit("ałajć").substr(lit(2)), + (rs: ResultSet) => assertEquals(rs.getString(1), "łajć") + )(withDB.all) + } + + test("substr vs substring") { + val s = lit("012345") + + checkExprDialect[String]( + s.substr(1, 1), + (rs: ResultSet) => assertEquals(rs.getString(1), "0"), + )(withDB.all) + checkExprDialect[String]( + s.substr(4, 2), + (rs: ResultSet) => assertEquals(rs.getString(1), "34") + )(withDB.all) + checkExprDialect[String]( + s.substr(4, 3), + (rs: ResultSet) => assertEquals(rs.getString(1), "345") + )(withDB.all) + + checkExprDialect[String]( + s.substring(0, 1), + (rs: ResultSet) => assertEquals(rs.getString(1), "0"), println + )(withDB.all) + checkExprDialect[String]( + s.substring(3, 5), + (rs: ResultSet) => assertEquals(rs.getString(1), "34"), println + )(withDB.all) + checkExprDialect[String]( + s.substring(3, 6), + (rs: ResultSet) => assertEquals(rs.getString(1), "345") + )(withDB.all) + } + + test("LIKE patterns") { + def checkValue(expected: Boolean)(rs: ResultSet) = assertEquals(rs.getBoolean(1), expected) + + checkExprDialect[Boolean]( + lit("abba").like(lit("abba")), + checkValue(true))(withDB.all) + + checkExprDialect[Boolean]( + lit("abba").like(lit("a_b_")), + checkValue(true))(withDB.all) + + checkExprDialect[Boolean]( + lit("abba").like(lit("bba")), + checkValue(false))(withDB.all) + + checkExprDialect[Boolean]( + lit("abba").like(lit("a%")), + checkValue(true))(withDB.all) + + checkExprDialect[Boolean]( + lit("abba").like(lit("%")), + checkValue(true))(withDB.all) + + checkExprDialect[Boolean]( + lit("abba").like(lit("___")), + checkValue(false))(withDB.all) + } + + test("LIKE patterns handle % and _ differently") { + def checkValue(expected: Boolean)(rs: ResultSet) = assertEquals(rs.getBoolean(1), expected) + + checkExprDialect[Boolean]( + lit("a%bc").like(lit("a%")), + checkValue(true))(withDB.all) + checkExprDialect[Boolean]( + lit("a%bc").like(lit("a" + literal_percent)), + checkValue(false))(withDB.all) + checkExprDialect[Boolean]( + lit("a%bc").like(lit("a" + literal_percent + "bc")), + checkValue(true))(withDB.all) + checkExprDialect[Boolean]( + lit("ab").like(lit("_b")), + checkValue(true))(withDB.all) + checkExprDialect[Boolean]( + lit("ab").like(lit(literal_underscore + "b")), + checkValue(false))(withDB.all) + checkExprDialect[Boolean]( + lit("_b").like(lit(literal_underscore + "b")), + checkValue(true))(withDB.all) + } + + test("concatenation of two strings") { + def checkValue(expected: String)(rs: ResultSet) = assertEquals(rs.getString(1), expected) + + checkExprDialect[String]( + lit("__a") + lit("bcd"), + checkValue("__abcd"))(withDB.all) + + checkExprDialect[String]( + lit("__a") + (lit("bc") + lit("d")), + checkValue("__abcd"))(withDB.all) + + checkExprDialect[String]( + (lit("__") + lit("a")) + lit("bcd"), + checkValue("__abcd"))(withDB.all) + } + + test("concatenation of multiple strings") { + def checkValue(expected: String)(rs: ResultSet) = assertEquals(rs.getString(1), expected) + + checkExprDialect[String]( + Expr.concat(Seq(lit("a"), lit("b"), lit("CCCC"))), + checkValue("abCCCC"))(withDB.all) + + checkExprDialect[String]( + Expr.concatWith(Seq(lit("a"), lit("b"), lit("CCCC")), lit("--")), + checkValue("a--b--CCCC"))(withDB.all) } } From 2052172f47681c016e2ad5129062a1e884ff4539 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Thu, 21 Nov 2024 21:26:14 +0100 Subject: [PATCH 066/106] string.reverse --- src/main/scala/tyql/dialects/dialect features.scala | 2 ++ src/main/scala/tyql/dialects/dialects.scala | 5 +++++ src/main/scala/tyql/expr/Expr.scala | 2 ++ src/main/scala/tyql/ir/QueryIRTree.scala | 1 + src/test/scala/test/integration/strings.scala | 12 ++++++++++++ 5 files changed, 22 insertions(+) diff --git a/src/main/scala/tyql/dialects/dialect features.scala b/src/main/scala/tyql/dialects/dialect features.scala index c902680..155e906 100644 --- a/src/main/scala/tyql/dialects/dialect features.scala +++ b/src/main/scala/tyql/dialects/dialect features.scala @@ -7,3 +7,5 @@ object DialectFeature: trait RandomUUID extends DialectFeature // TODO also refactor this one just like the above two are refactored to be late-binding trait RandomIntegerInInclusiveRange extends DialectFeature // TODO later change it to not use raw SQL maybe? + + trait ReversibleStrings extends DialectFeature diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index 909cae8..806bccd 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -61,6 +61,7 @@ object Dialect: given RandomUUID = new RandomUUID {} // TODO now that we have precedence, fix the parenthesization rules for this! given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} + given ReversibleStrings = new ReversibleStrings {} object mysql: given Dialect = new MySQLDialect @@ -80,6 +81,7 @@ object Dialect: given RandomUUID = new RandomUUID {} // TODO now that we have precedence, fix the parenthesization rules for this! given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} + given ReversibleStrings = new ReversibleStrings {} object mariadb: // XXX MariaDB extends MySQL @@ -90,6 +92,7 @@ object Dialect: given RandomFloat = mysql.given_RandomFloat given RandomUUID = mysql.given_RandomUUID given RandomIntegerInInclusiveRange = mysql.given_RandomIntegerInInclusiveRange + given ReversibleStrings = mysql.given_ReversibleStrings object sqlite: given Dialect = new Dialect @@ -108,6 +111,7 @@ object Dialect: given RandomFloat = new RandomFloat {} // TODO now that we have precedence, fix the parenthesization rules for this! given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} + given ReversibleStrings = new ReversibleStrings {} object h2: given Dialect = new Dialect @@ -145,3 +149,4 @@ object Dialect: given RandomUUID = new RandomUUID {} // TODO now that we have precedence, fix the parenthesization rules for this! given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} + given ReversibleStrings = new ReversibleStrings {} diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index 74eeaf9..ecb1e14 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -134,6 +134,7 @@ object Expr: substr(Expr.Plus(Expr.IntLit(1), start).asInstanceOf[Expr[Int, S2]], null) // XXX how to avoid this cast def like[S2 <: ExprShape](pattern: Expr[String, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = Expr.StrLike(x, pattern) def `+`[S2 <: ExprShape](y: Expr[String, S2]): Expr[String, CalculatedShape[S1, S2]] = Expr.StrConcat(x, Seq(y)) + def reverse(using DialectFeature.ReversibleStrings): Expr[String, S1] = Expr.StrReverse(x) def concat[S <: ExprShape](strs: Seq[Expr[String, S]]): Expr[String, S] = assert(strs.nonEmpty, "concat requires at least one argument") @@ -228,6 +229,7 @@ object Expr: case class StrConcat[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[String, S1], $xs: Seq[Expr[String, S2]]) extends Expr[String, CalculatedShape[S1, S2]] // First one has a different shape so you can use it as an opertor between two arguments that have different shapes case class StrConcatUniform[S1 <: ExprShape]($x: Expr[String, S1], $xs: Seq[Expr[String, S1]]) extends Expr[String, S1] case class StrConcatSeparator[S1 <: ExprShape, S3 <: ExprShape]($sep: Expr[String, S3], $x: Expr[String, S1], $xs: Seq[Expr[String, S1]]) extends Expr[String, CalculatedShape[S1, S3]] + case class StrReverse[S <: ExprShape]($x: Expr[String, S]) extends Expr[String, S] case class RandomUUID() extends Expr[String, NonScalarExpr] // XXX NonScalarExpr? case class RandomFloat() extends Expr[Double, NonScalarExpr] // XXX NonScalarExpr? diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index d4b90c5..699c624 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -401,6 +401,7 @@ object QueryIRTree: case c: Expr.SimpleCase[?, ?, ?, ?] => SimpleCaseOp(generateExpr(c.$expr, symbols), c.$cases.map(w => (generateExpr(w._1, symbols), generateExpr(w._2, symbols))), c.$else.map(generateExpr(_, symbols)), c) case l: Expr.Lower[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"LOWER($o)", l) case l: Expr.Upper[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"UPPER($o)", l) + case l: Expr.StrReverse[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"REVERSE($o)", l) case l: Expr.Trim[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"TRIM($o)", l) case l: Expr.LTrim[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"LTRIM($o)", l) case l: Expr.RTrim[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"RTRIM($o)", l) diff --git a/src/test/scala/test/integration/strings.scala b/src/test/scala/test/integration/strings.scala index 7046f70..1541846 100644 --- a/src/test/scala/test/integration/strings.scala +++ b/src/test/scala/test/integration/strings.scala @@ -181,4 +181,16 @@ class StringTests extends FunSuite { Expr.concatWith(Seq(lit("a"), lit("b"), lit("CCCC")), lit("--")), checkValue("a--b--CCCC"))(withDB.all) } + + test("reverse") { + def checkValue(expected: String)(rs: ResultSet) = assertEquals(rs.getString(1), expected) + import tyql.DialectFeature.ReversibleStrings + given ReversibleStrings = new ReversibleStrings {} + + checkExprDialect[String](lit("aBcł").reverse, checkValue("łcBa"))(withDB.postgres) + checkExprDialect[String](lit("aBcł").reverse, checkValue("łcBa"))(withDB.sqlite) + checkExprDialect[String](lit("aBcł").reverse, checkValue("łcBa"))(withDB.duckdb) + checkExprDialect[String](lit("aBcł").reverse, checkValue("łcBa"))(withDB.mysql) + checkExprDialect[String](lit("aBcł").reverse, checkValue("łcBa"))(withDB.mariadb) + } } From 5daa59fcbf24e45f721af9a1e83c30d7c90380fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Thu, 21 Nov 2024 21:33:13 +0100 Subject: [PATCH 067/106] string.repeat --- src/main/scala/tyql/dialects/dialects.scala | 2 ++ src/main/scala/tyql/expr/Expr.scala | 2 ++ src/main/scala/tyql/ir/QueryIRTree.scala | 9 +++++++++ src/test/scala/test/integration/strings.scala | 5 +++++ 4 files changed, 18 insertions(+) diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index 806bccd..c5ba176 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -29,6 +29,7 @@ trait Dialect: def feature_RandomFloat_rawSQL: Option[String] = unsupportedFeature("RandomFloat") def feature_RandomInt_rawSQL(a: String, b: String): String = unsupportedFeature("RandomInt") + def needsStringRepeatPolyfill: Boolean = false object Dialect: val literal_percent = '\uE000' @@ -106,6 +107,7 @@ object Dialect: // TODO now that we have precedence, fix the parenthesization rules for this! override def feature_RandomFloat_rawSQL: Option[String] = Some("(0.5 - RANDOM() / CAST(-9223372036854775808 AS REAL) / 2)") override def feature_RandomInt_rawSQL(a: String, b: String): String = s"cast(abs(random() % ($b - $a + 1) + $a) as integer)" + override def needsStringRepeatPolyfill: Boolean = true // TODO think about how quoting strings like this impacts simplifications and efficient generation given RandomFloat = new RandomFloat {} diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index ecb1e14..5459162 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -135,6 +135,7 @@ object Expr: def like[S2 <: ExprShape](pattern: Expr[String, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = Expr.StrLike(x, pattern) def `+`[S2 <: ExprShape](y: Expr[String, S2]): Expr[String, CalculatedShape[S1, S2]] = Expr.StrConcat(x, Seq(y)) def reverse(using DialectFeature.ReversibleStrings): Expr[String, S1] = Expr.StrReverse(x) + def repeat[S2 <: ExprShape](n: Expr[Int, S2]): Expr[String, CalculatedShape[S1, S2]] = Expr.StrRepeat(x, n) def concat[S <: ExprShape](strs: Seq[Expr[String, S]]): Expr[String, S] = assert(strs.nonEmpty, "concat requires at least one argument") @@ -230,6 +231,7 @@ object Expr: case class StrConcatUniform[S1 <: ExprShape]($x: Expr[String, S1], $xs: Seq[Expr[String, S1]]) extends Expr[String, S1] case class StrConcatSeparator[S1 <: ExprShape, S3 <: ExprShape]($sep: Expr[String, S3], $x: Expr[String, S1], $xs: Seq[Expr[String, S1]]) extends Expr[String, CalculatedShape[S1, S3]] case class StrReverse[S <: ExprShape]($x: Expr[String, S]) extends Expr[String, S] + case class StrRepeat[S1 <: ExprShape, S2 <: ExprShape]($s: Expr[String, S1], $n: Expr[Int, S2]) extends Expr[String, CalculatedShape[S1, S2]] case class RandomUUID() extends Expr[String, NonScalarExpr] // XXX NonScalarExpr? case class RandomFloat() extends Expr[Double, NonScalarExpr] // XXX NonScalarExpr? diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index 699c624..8b19995 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -424,6 +424,15 @@ object QueryIRTree: case Seq(f) => s"$f($o)" case Seq(inner, outer) => s"$outer($inner($o))"), l) + case l: Expr.StrRepeat[?, ?] => + if !d.needsStringRepeatPolyfill then + FunctionCallOp("REPEAT", Seq(generateExpr(l.$s, symbols), generateExpr(l.$n, symbols)), l) + else + FunctionCallOp("REPLACE", Seq( + FunctionCallOp("PRINTF", Seq(Literal("'%.*c'", null), generateExpr(l.$n, symbols), Literal("'x'", null)), l), + Literal("'x'", null), + generateExpr(l.$s, symbols) + ), l) case a: AggregationExpr[?] => generateAggregation(a, symbols) case a: Aggregation[?, ?] => generateQuery(a, symbols).appendFlag(SelectFlags.ExprLevel) case list: Expr.ListExpr[?] => ListTypeExpr(list.$elements.map(generateExpr(_, symbols)), list) diff --git a/src/test/scala/test/integration/strings.scala b/src/test/scala/test/integration/strings.scala index 1541846..850a9e7 100644 --- a/src/test/scala/test/integration/strings.scala +++ b/src/test/scala/test/integration/strings.scala @@ -193,4 +193,9 @@ class StringTests extends FunSuite { checkExprDialect[String](lit("aBcł").reverse, checkValue("łcBa"))(withDB.mysql) checkExprDialect[String](lit("aBcł").reverse, checkValue("łcBa"))(withDB.mariadb) } + + test("repeat") { + def checkValue(expected: String)(rs: ResultSet) = assertEquals(rs.getString(1), expected) + checkExprDialect[String](lit("aB").repeat(lit(3)), checkValue("aBaBaB"))(withDB.all) + } } From 8af0732ea56ac55986d41574ff1cbdaeeebc0d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Thu, 21 Nov 2024 21:56:15 +0100 Subject: [PATCH 068/106] string.{lpad, rpad} --- src/main/scala/tyql/dialects/dialects.scala | 2 ++ src/main/scala/tyql/expr/Expr.scala | 4 ++++ src/main/scala/tyql/ir/QueryIRTree.scala | 23 +++++++++++++++++++ src/test/scala/test/integration/strings.scala | 17 ++++++++++++++ 4 files changed, 46 insertions(+) diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index c5ba176..2bd1689 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -30,6 +30,7 @@ trait Dialect: def feature_RandomInt_rawSQL(a: String, b: String): String = unsupportedFeature("RandomInt") def needsStringRepeatPolyfill: Boolean = false + def needsStringLPadRPadPolyfill: Boolean = false object Dialect: val literal_percent = '\uE000' @@ -108,6 +109,7 @@ object Dialect: override def feature_RandomFloat_rawSQL: Option[String] = Some("(0.5 - RANDOM() / CAST(-9223372036854775808 AS REAL) / 2)") override def feature_RandomInt_rawSQL(a: String, b: String): String = s"cast(abs(random() % ($b - $a + 1) + $a) as integer)" override def needsStringRepeatPolyfill: Boolean = true + override def needsStringLPadRPadPolyfill: Boolean = true // TODO think about how quoting strings like this impacts simplifications and efficient generation given RandomFloat = new RandomFloat {} diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index 5459162..93e168d 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -136,6 +136,8 @@ object Expr: def `+`[S2 <: ExprShape](y: Expr[String, S2]): Expr[String, CalculatedShape[S1, S2]] = Expr.StrConcat(x, Seq(y)) def reverse(using DialectFeature.ReversibleStrings): Expr[String, S1] = Expr.StrReverse(x) def repeat[S2 <: ExprShape](n: Expr[Int, S2]): Expr[String, CalculatedShape[S1, S2]] = Expr.StrRepeat(x, n) + def lpad[S2 <: ExprShape](len: Expr[Int, S2], pad: Expr[String, S2]): Expr[String, CalculatedShape[S1, S2]] = Expr.StrLPad(x, len, pad) + def rpad[S2 <: ExprShape](len: Expr[Int, S2], pad: Expr[String, S2]): Expr[String, CalculatedShape[S1, S2]] = Expr.StrRPad(x, len, pad) def concat[S <: ExprShape](strs: Seq[Expr[String, S]]): Expr[String, S] = assert(strs.nonEmpty, "concat requires at least one argument") @@ -232,6 +234,8 @@ object Expr: case class StrConcatSeparator[S1 <: ExprShape, S3 <: ExprShape]($sep: Expr[String, S3], $x: Expr[String, S1], $xs: Seq[Expr[String, S1]]) extends Expr[String, CalculatedShape[S1, S3]] case class StrReverse[S <: ExprShape]($x: Expr[String, S]) extends Expr[String, S] case class StrRepeat[S1 <: ExprShape, S2 <: ExprShape]($s: Expr[String, S1], $n: Expr[Int, S2]) extends Expr[String, CalculatedShape[S1, S2]] + case class StrLPad[S1 <: ExprShape, S2 <: ExprShape]($s: Expr[String, S1], $len: Expr[Int, S2], $pad: Expr[String, S2]) extends Expr[String, CalculatedShape[S1, S2]] + case class StrRPad[S1 <: ExprShape, S2 <: ExprShape]($s: Expr[String, S1], $len: Expr[Int, S2], $pad: Expr[String, S2]) extends Expr[String, CalculatedShape[S1, S2]] case class RandomUUID() extends Expr[String, NonScalarExpr] // XXX NonScalarExpr? case class RandomFloat() extends Expr[Double, NonScalarExpr] // XXX NonScalarExpr? diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index 8b19995..3cefcba 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -433,6 +433,29 @@ object QueryIRTree: Literal("'x'", null), generateExpr(l.$s, symbols) ), l) + case l: Expr.StrLPad[?, ?] => + if !d.needsStringLPadRPadPolyfill then + FunctionCallOp("LPAD", Seq(generateExpr(l.$s, symbols), generateExpr(l.$len, symbols), generateExpr(l.$pad, symbols)), l) + else + val strStr = "STR3056960" + val padStr = "PAD2086613" + val numStr = "NUM1932354" + RawSQLInsertOp(s"substr(substr(replace(hex(zeroblob(NUM1932354)), '00', PAD2086613), 1, NUM1932354 - length(STR3056960)) || STR3056960, 1, NUM1932354)", + Map("STR3056960" -> generateExpr(l.$s, symbols), "PAD2086613" -> generateExpr(l.$pad, symbols), "NUM1932354" -> generateExpr(l.$len, symbols)), + Precedence.Additive, + l) + // TODO check if this string replacement trick is nestable, it should be... + case l: Expr.StrRPad[?, ?] => + if !d.needsStringLPadRPadPolyfill then + FunctionCallOp("RPAD", Seq(generateExpr(l.$s, symbols), generateExpr(l.$len, symbols), generateExpr(l.$pad, symbols)), l) + else + val strStr = "STR6156335" + val padStr = "PAD250762" + val numStr = "NUM7165461" + RawSQLInsertOp(s"substr(STR6156335 || substr(replace(hex(zeroblob(NUM7165461)), '00', PAD250762), 1, NUM7165461 - length(STR6156335)), 1, NUM7165461)", + Map("STR6156335" -> generateExpr(l.$s, symbols), "PAD250762" -> generateExpr(l.$pad, symbols), "NUM7165461" -> generateExpr(l.$len, symbols)), + Precedence.Additive, + l) case a: AggregationExpr[?] => generateAggregation(a, symbols) case a: Aggregation[?, ?] => generateQuery(a, symbols).appendFlag(SelectFlags.ExprLevel) case list: Expr.ListExpr[?] => ListTypeExpr(list.$elements.map(generateExpr(_, symbols)), list) diff --git a/src/test/scala/test/integration/strings.scala b/src/test/scala/test/integration/strings.scala index 850a9e7..0e9efd3 100644 --- a/src/test/scala/test/integration/strings.scala +++ b/src/test/scala/test/integration/strings.scala @@ -195,7 +195,24 @@ class StringTests extends FunSuite { } test("repeat") { + // TODO check what happens on werird numbers def checkValue(expected: String)(rs: ResultSet) = assertEquals(rs.getString(1), expected) checkExprDialect[String](lit("aB").repeat(lit(3)), checkValue("aBaBaB"))(withDB.all) } + + test("lpad rpad") { + def checkValue(expected: String)(rs: ResultSet) = assertEquals(rs.getString(1), expected) + checkExprDialect[String](lit("1234").lpad(lit(7), lit("ZZ")), checkValue("ZZZ1234"))(withDB.all) + checkExprDialect[String](lit("1234").rpad(lit(7), lit("ZZ")), checkValue("1234ZZZ"))(withDB.all) + + checkExprDialect[String](lit("1234").lpad(lit(4), lit("ZZ")), checkValue("1234"))(withDB.all) + checkExprDialect[String](lit("1234").rpad(lit(4), lit("ZZ")), checkValue("1234"))(withDB.all) + + checkExprDialect[String](lit("1234").lpad(lit(2), lit("ZZ")), checkValue("12"))(withDB.all) + checkExprDialect[String](lit("1234").rpad(lit(2), lit("ZZ")), checkValue("12"))(withDB.all) + checkExprDialect[String](lit("1234").lpad(lit(0), lit("ZZ")), checkValue(""))(withDB.all) + checkExprDialect[String](lit("1234").rpad(lit(0), lit("ZZ")), checkValue(""))(withDB.all) + + // -1 will give you '' or null depending on the DB + } } From 2bac1ec94910c069720cbc85ad89b6d6f2660a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Thu, 21 Nov 2024 22:19:34 +0100 Subject: [PATCH 069/106] string.findPosition --- src/main/scala/tyql/dialects/dialects.scala | 5 +++++ src/main/scala/tyql/expr/Expr.scala | 2 ++ src/main/scala/tyql/ir/QueryIRTree.scala | 15 ++++++++++----- src/test/scala/test/integration/strings.scala | 15 ++++++++++++++- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index 2bd1689..40a4f9e 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -32,6 +32,8 @@ trait Dialect: def needsStringRepeatPolyfill: Boolean = false def needsStringLPadRPadPolyfill: Boolean = false + def stringPositionFindingVia: String = "LOCATE" + object Dialect: val literal_percent = '\uE000' val literal_underscore = '\uE001' @@ -58,6 +60,7 @@ object Dialect: override def feature_RandomFloat_functionName: Option[String] = Some("random") override def feature_RandomFloat_rawSQL: Option[String] = None override def feature_RandomInt_rawSQL(a: String, b: String): String = s"floor(random() * ($b - $a + 1) + $a)::integer" + override def stringPositionFindingVia: String = "POSITION" given RandomFloat = new RandomFloat {} given RandomUUID = new RandomUUID {} @@ -110,6 +113,7 @@ object Dialect: override def feature_RandomInt_rawSQL(a: String, b: String): String = s"cast(abs(random() % ($b - $a + 1) + $a) as integer)" override def needsStringRepeatPolyfill: Boolean = true override def needsStringLPadRPadPolyfill: Boolean = true + override def stringPositionFindingVia: String = "INSTR" // TODO think about how quoting strings like this impacts simplifications and efficient generation given RandomFloat = new RandomFloat {} @@ -148,6 +152,7 @@ object Dialect: override def feature_RandomFloat_functionName: Option[String] = Some("random") override def feature_RandomFloat_rawSQL: Option[String] = None override def feature_RandomInt_rawSQL(a: String, b: String): String = s"floor(random() * ($b - $a + 1) + $a)::integer" + override def stringPositionFindingVia: String = "POSITION" given RandomFloat = new RandomFloat {} given RandomUUID = new RandomUUID {} diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index 93e168d..15fe1f3 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -138,6 +138,7 @@ object Expr: def repeat[S2 <: ExprShape](n: Expr[Int, S2]): Expr[String, CalculatedShape[S1, S2]] = Expr.StrRepeat(x, n) def lpad[S2 <: ExprShape](len: Expr[Int, S2], pad: Expr[String, S2]): Expr[String, CalculatedShape[S1, S2]] = Expr.StrLPad(x, len, pad) def rpad[S2 <: ExprShape](len: Expr[Int, S2], pad: Expr[String, S2]): Expr[String, CalculatedShape[S1, S2]] = Expr.StrRPad(x, len, pad) + def findPosition[S2 <: ExprShape](substr: Expr[String, S2]): Expr[Int, CalculatedShape[S1, S2]] = Expr.StrPositionIn(substr, x) def concat[S <: ExprShape](strs: Seq[Expr[String, S]]): Expr[String, S] = assert(strs.nonEmpty, "concat requires at least one argument") @@ -236,6 +237,7 @@ object Expr: case class StrRepeat[S1 <: ExprShape, S2 <: ExprShape]($s: Expr[String, S1], $n: Expr[Int, S2]) extends Expr[String, CalculatedShape[S1, S2]] case class StrLPad[S1 <: ExprShape, S2 <: ExprShape]($s: Expr[String, S1], $len: Expr[Int, S2], $pad: Expr[String, S2]) extends Expr[String, CalculatedShape[S1, S2]] case class StrRPad[S1 <: ExprShape, S2 <: ExprShape]($s: Expr[String, S1], $len: Expr[Int, S2], $pad: Expr[String, S2]) extends Expr[String, CalculatedShape[S1, S2]] + case class StrPositionIn[S1 <: ExprShape, S2 <: ExprShape]($substr: Expr[String, S2], $string: Expr[String, S1]) extends Expr[Int, CalculatedShape[S1, S2]] case class RandomUUID() extends Expr[String, NonScalarExpr] // XXX NonScalarExpr? case class RandomFloat() extends Expr[Double, NonScalarExpr] // XXX NonScalarExpr? diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index 3cefcba..ec6dbc6 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -428,11 +428,12 @@ object QueryIRTree: if !d.needsStringRepeatPolyfill then FunctionCallOp("REPEAT", Seq(generateExpr(l.$s, symbols), generateExpr(l.$n, symbols)), l) else - FunctionCallOp("REPLACE", Seq( - FunctionCallOp("PRINTF", Seq(Literal("'%.*c'", null), generateExpr(l.$n, symbols), Literal("'x'", null)), l), - Literal("'x'", null), - generateExpr(l.$s, symbols) - ), l) + val strStr = "STR1370031" + val numStr = "NUM6221709" + RawSQLInsertOp(s"SUBSTR(REPLACE(PRINTF('%.*c', $numStr, 'x'), 'x', $strStr), 1, length($strStr)*$numStr)", + Map(strStr -> generateExpr(l.$s, symbols), numStr -> generateExpr(l.$n, symbols)), + Precedence.Unary, + l) case l: Expr.StrLPad[?, ?] => if !d.needsStringLPadRPadPolyfill then FunctionCallOp("LPAD", Seq(generateExpr(l.$s, symbols), generateExpr(l.$len, symbols), generateExpr(l.$pad, symbols)), l) @@ -456,6 +457,10 @@ object QueryIRTree: Map("STR6156335" -> generateExpr(l.$s, symbols), "PAD250762" -> generateExpr(l.$pad, symbols), "NUM7165461" -> generateExpr(l.$len, symbols)), Precedence.Additive, l) + case l: Expr.StrPositionIn[?, ?] => d.stringPositionFindingVia match + case "POSITION" => BinExprOp(generateExpr(l.$substr, symbols), generateExpr(l.$string, symbols), (l, r) => s"POSITION($l IN $r)", Precedence.Unary, l) + case "LOCATE" => FunctionCallOp("LOCATE", Seq(generateExpr(l.$substr, symbols), generateExpr(l.$string, symbols)), l) + case "INSTR" => FunctionCallOp("INSTR", Seq(generateExpr(l.$string, symbols), generateExpr(l.$substr, symbols)), l) case a: AggregationExpr[?] => generateAggregation(a, symbols) case a: Aggregation[?, ?] => generateQuery(a, symbols).appendFlag(SelectFlags.ExprLevel) case list: Expr.ListExpr[?] => ListTypeExpr(list.$elements.map(generateExpr(_, symbols)), list) diff --git a/src/test/scala/test/integration/strings.scala b/src/test/scala/test/integration/strings.scala index 0e9efd3..155e828 100644 --- a/src/test/scala/test/integration/strings.scala +++ b/src/test/scala/test/integration/strings.scala @@ -195,9 +195,10 @@ class StringTests extends FunSuite { } test("repeat") { - // TODO check what happens on werird numbers def checkValue(expected: String)(rs: ResultSet) = assertEquals(rs.getString(1), expected) checkExprDialect[String](lit("aB").repeat(lit(3)), checkValue("aBaBaB"))(withDB.all) + checkExprDialect[String](lit("aB").repeat(lit(1)), checkValue("aB"))(withDB.all) + checkExprDialect[String](lit("aB").repeat(lit(0)), checkValue(""), println)(withDB.all) } test("lpad rpad") { @@ -215,4 +216,16 @@ class StringTests extends FunSuite { // -1 will give you '' or null depending on the DB } + + test("string position") { + def checkValue(expected: Int)(rs: ResultSet) = assertEquals(rs.getInt(1), expected) + checkExprDialect[Int](lit("aBc").findPosition(lit("B")), checkValue(2))(withDB.all) + // TODO XXX now the case sensitivity is not handled at all and is DB-specific! + for (r <- Seq(withDB.postgres[Unit], withDB.h2[Unit], withDB.duckdb[Unit], withDB.sqlite[Unit])) { + checkExprDialect[Int](lit("abba aBba ABba").findPosition(lit("Bb")), checkValue(7))(r) + } + for (r <- Seq(withDB.mysql[Unit], withDB.mariadb[Unit])) { + checkExprDialect[Int](lit("abba aBba ABba").findPosition(lit("Bb")), checkValue(2))(r) + } + } } From a9d7146fd803c861923a663d893ccb6ea1eda835 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Thu, 21 Nov 2024 22:25:15 +0100 Subject: [PATCH 070/106] remove printlns, use lit() to supress warnings --- src/test/scala/test/integration/random.scala | 2 +- src/test/scala/test/integration/strings.scala | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/test/scala/test/integration/random.scala b/src/test/scala/test/integration/random.scala index 855618d..f74fc50 100644 --- a/src/test/scala/test/integration/random.scala +++ b/src/test/scala/test/integration/random.scala @@ -169,7 +169,7 @@ class RandomTests extends FunSuite { { import tyql.DialectFeature.RandomIntegerInInclusiveRange given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} - q = Expr.randomInt(tyql.lit(101), 101) + q = Expr.randomInt(tyql.lit(101), tyql.lit(101)) } { diff --git a/src/test/scala/test/integration/strings.scala b/src/test/scala/test/integration/strings.scala index 155e828..bf7e14d 100644 --- a/src/test/scala/test/integration/strings.scala +++ b/src/test/scala/test/integration/strings.scala @@ -77,28 +77,28 @@ class StringTests extends FunSuite { val s = lit("012345") checkExprDialect[String]( - s.substr(1, 1), + s.substr(lit(1), lit(1)), (rs: ResultSet) => assertEquals(rs.getString(1), "0"), )(withDB.all) checkExprDialect[String]( - s.substr(4, 2), + s.substr(lit(4), lit(2)), (rs: ResultSet) => assertEquals(rs.getString(1), "34") )(withDB.all) checkExprDialect[String]( - s.substr(4, 3), + s.substr(lit(4), lit(3)), (rs: ResultSet) => assertEquals(rs.getString(1), "345") )(withDB.all) checkExprDialect[String]( - s.substring(0, 1), - (rs: ResultSet) => assertEquals(rs.getString(1), "0"), println + s.substring(lit(0), lit(1)), + (rs: ResultSet) => assertEquals(rs.getString(1), "0") )(withDB.all) checkExprDialect[String]( - s.substring(3, 5), - (rs: ResultSet) => assertEquals(rs.getString(1), "34"), println + s.substring(lit(3), lit(5)), + (rs: ResultSet) => assertEquals(rs.getString(1), "34") )(withDB.all) checkExprDialect[String]( - s.substring(3, 6), + s.substring(lit(3), lit(6)), (rs: ResultSet) => assertEquals(rs.getString(1), "345") )(withDB.all) } @@ -198,7 +198,7 @@ class StringTests extends FunSuite { def checkValue(expected: String)(rs: ResultSet) = assertEquals(rs.getString(1), expected) checkExprDialect[String](lit("aB").repeat(lit(3)), checkValue("aBaBaB"))(withDB.all) checkExprDialect[String](lit("aB").repeat(lit(1)), checkValue("aB"))(withDB.all) - checkExprDialect[String](lit("aB").repeat(lit(0)), checkValue(""), println)(withDB.all) + checkExprDialect[String](lit("aB").repeat(lit(0)), checkValue(""))(withDB.all) } test("lpad rpad") { From c62adcf93bb1825e61e8b7345dfd4d6bbb2d64ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Fri, 22 Nov 2024 13:37:10 +0100 Subject: [PATCH 071/106] dialect: octet_length generation simplified --- src/main/scala/tyql/dialects/dialects.scala | 4 ++-- src/main/scala/tyql/ir/QueryIRTree.scala | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index 40a4f9e..bed8844 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -20,7 +20,7 @@ trait Dialect: def quoteBooleanLiteral(in: Boolean): String val stringLengthByCharacters: String = "CHAR_LENGTH" - val stringLengthByBytes: Seq[String] = Seq("OCTET_LENGTH") // series of functions to nest, in order from inner to outer + val stringLengthBytesNeedsEncodeFirst: Boolean = false val xorOperatorSupportedNatively = false @@ -147,7 +147,7 @@ object Dialect: with BooleanLiterals.UseTrueFalse: override def name(): String = "DuckDB Dialect" override val stringLengthByCharacters = "length" - override val stringLengthByBytes = Seq("encode", "octet_length") + override val stringLengthBytesNeedsEncodeFirst = true override def feature_RandomUUID_functionName: String = "uuid" override def feature_RandomFloat_functionName: Option[String] = Some("random") override def feature_RandomFloat_rawSQL: Option[String] = None diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index ec6dbc6..7d8c8e4 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -6,6 +6,7 @@ import tyql.ResultTag.NamedTupleTag import language.experimental.namedTuples import NamedTuple.NamedTuple import NamedTupleDecomposition.* +import org.checkerframework.checker.units.qual.m /** * Logical query plan tree. @@ -419,11 +420,7 @@ object QueryIRTree: case l: Expr.StrConcatUniform[?] => FunctionCallOp("CONCAT", (Seq(l.$x) ++ l.$xs).map(generateExpr(_, symbols)), l) case l: Expr.StrConcatSeparator[?, ?] => FunctionCallOp("CONCAT_WS", (Seq(l.$sep, l.$x) ++ l.$xs).map(generateExpr(_, symbols)), l) case l: Expr.StringCharLength[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => s"${d.stringLengthByCharacters}($o)", l) - case l: Expr.StringByteLength[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => - (d.stringLengthByBytes match - case Seq(f) => s"$f($o)" - case Seq(inner, outer) => s"$outer($inner($o))"), - l) + case l: Expr.StringByteLength[?] => UnaryExprOp(generateExpr(l.$x, symbols), o => if d.stringLengthBytesNeedsEncodeFirst then s"OCTET_LENGTH(ENCODE($o))" else s"OCTET_LENGTH($o)", l) case l: Expr.StrRepeat[?, ?] => if !d.needsStringRepeatPolyfill then FunctionCallOp("REPEAT", Seq(generateExpr(l.$s, symbols), generateExpr(l.$n, symbols)), l) From 3b5b5ee0c7c1ef3a9ab6ccf805800c20b218f058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Fri, 22 Nov 2024 15:27:30 +0100 Subject: [PATCH 072/106] SQL snippets now have precedence metadata --- .../tyql/dialects/dialect features.scala | 3 +- src/main/scala/tyql/dialects/dialects.scala | 46 ++++++++++-------- src/main/scala/tyql/expr/Expr.scala | 3 -- src/main/scala/tyql/ir/QueryIRNode.scala | 14 +++++- src/main/scala/tyql/ir/QueryIRTree.scala | 48 +++++++++---------- src/main/scala/tyql/snippet.scala | 22 +++++++++ src/test/scala/test/integration/random.scala | 26 +++++----- 7 files changed, 97 insertions(+), 65 deletions(-) create mode 100644 src/main/scala/tyql/snippet.scala diff --git a/src/main/scala/tyql/dialects/dialect features.scala b/src/main/scala/tyql/dialects/dialect features.scala index 155e906..5923d96 100644 --- a/src/main/scala/tyql/dialects/dialect features.scala +++ b/src/main/scala/tyql/dialects/dialect features.scala @@ -5,7 +5,6 @@ trait DialectFeature object DialectFeature: trait RandomFloat extends DialectFeature trait RandomUUID extends DialectFeature - // TODO also refactor this one just like the above two are refactored to be late-binding - trait RandomIntegerInInclusiveRange extends DialectFeature // TODO later change it to not use raw SQL maybe? + trait RandomIntegerInInclusiveRange extends DialectFeature trait ReversibleStrings extends DialectFeature diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index bed8844..1094b98 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -26,8 +26,8 @@ trait Dialect: def feature_RandomUUID_functionName: String = unsupportedFeature("RandomUUID") def feature_RandomFloat_functionName: Option[String] = unsupportedFeature("RandomFloat") - def feature_RandomFloat_rawSQL: Option[String] = unsupportedFeature("RandomFloat") - def feature_RandomInt_rawSQL(a: String, b: String): String = unsupportedFeature("RandomInt") + def feature_RandomFloat_rawSQL: Option[SqlSnippet] = unsupportedFeature("RandomFloat") + def feature_RandomInt_rawSQL: SqlSnippet = unsupportedFeature("RandomInt") def needsStringRepeatPolyfill: Boolean = false def needsStringLPadRPadPolyfill: Boolean = false @@ -58,13 +58,15 @@ object Dialect: override val stringLengthByCharacters: String = "length" override def feature_RandomUUID_functionName: String = "gen_random_uuid" override def feature_RandomFloat_functionName: Option[String] = Some("random") - override def feature_RandomFloat_rawSQL: Option[String] = None - override def feature_RandomInt_rawSQL(a: String, b: String): String = s"floor(random() * ($b - $a + 1) + $a)::integer" + override def feature_RandomFloat_rawSQL: Option[SqlSnippet] = None + override def feature_RandomInt_rawSQL: SqlSnippet = + val a = ("a", Precedence.Additive) + val b = ("b", Precedence.Additive) + SqlSnippet(Precedence.Unary, snippet"floor(random() * ($b - $a + 1) + $a)::integer") override def stringPositionFindingVia: String = "POSITION" given RandomFloat = new RandomFloat {} given RandomUUID = new RandomUUID {} - // TODO now that we have precedence, fix the parenthesization rules for this! given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} given ReversibleStrings = new ReversibleStrings {} @@ -79,12 +81,14 @@ object Dialect: override val xorOperatorSupportedNatively = true override def feature_RandomUUID_functionName: String = "UUID" override def feature_RandomFloat_functionName: Option[String] = Some("rand") - override def feature_RandomFloat_rawSQL: Option[String] = None - override def feature_RandomInt_rawSQL(a: String, b: String): String = s"floor(rand() * ($b - $a + 1) + $a)" + override def feature_RandomFloat_rawSQL: Option[SqlSnippet] = None + override def feature_RandomInt_rawSQL: SqlSnippet = + val a = ("a", Precedence.Additive) + val b = ("b", Precedence.Additive) + SqlSnippet(Precedence.Unary, snippet"floor(rand() * ($b - $a + 1) + $a)") given RandomFloat = new RandomFloat {} given RandomUUID = new RandomUUID {} - // TODO now that we have precedence, fix the parenthesization rules for this! given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} given ReversibleStrings = new ReversibleStrings {} @@ -108,16 +112,16 @@ object Dialect: def name() = "SQLite Dialect" override val stringLengthByCharacters = "length" override def feature_RandomFloat_functionName: Option[String] = None - // TODO now that we have precedence, fix the parenthesization rules for this! - override def feature_RandomFloat_rawSQL: Option[String] = Some("(0.5 - RANDOM() / CAST(-9223372036854775808 AS REAL) / 2)") - override def feature_RandomInt_rawSQL(a: String, b: String): String = s"cast(abs(random() % ($b - $a + 1) + $a) as integer)" + override def feature_RandomFloat_rawSQL: Option[SqlSnippet] = Some(SqlSnippet(Precedence.Unary, snippet"(0.5 - RANDOM() / CAST(-9223372036854775808 AS REAL) / 2)")) + override def feature_RandomInt_rawSQL: SqlSnippet = + val a = ("a", Precedence.Additive) + val b = ("b", Precedence.Additive) + SqlSnippet(Precedence.Unary, snippet"cast(abs(random() % ($b - $a + 1) + $a) as integer)") override def needsStringRepeatPolyfill: Boolean = true override def needsStringLPadRPadPolyfill: Boolean = true override def stringPositionFindingVia: String = "INSTR" - // TODO think about how quoting strings like this impacts simplifications and efficient generation given RandomFloat = new RandomFloat {} - // TODO now that we have precedence, fix the parenthesization rules for this! given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} given ReversibleStrings = new ReversibleStrings {} @@ -131,12 +135,14 @@ object Dialect: override val stringLengthByCharacters = "length" override def feature_RandomUUID_functionName: String = "RANDOM_UUID" override def feature_RandomFloat_functionName: Option[String] = Some("rand") - override def feature_RandomFloat_rawSQL: Option[String] = None - override def feature_RandomInt_rawSQL(a: String, b: String): String = s"floor(rand() * ($b - $a + 1) + $a)" + override def feature_RandomFloat_rawSQL: Option[SqlSnippet] = None + override def feature_RandomInt_rawSQL: SqlSnippet = + val a = ("a", Precedence.Additive) + val b = ("b", Precedence.Additive) + SqlSnippet(Precedence.Unary, snippet"floor(rand() * ($b - $a + 1) + $a)") given RandomFloat = new RandomFloat {} given RandomUUID = new RandomUUID {} - // TODO now that we have precedence, fix the parenthesization rules for this! given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} object duckdb: @@ -150,12 +156,14 @@ object Dialect: override val stringLengthBytesNeedsEncodeFirst = true override def feature_RandomUUID_functionName: String = "uuid" override def feature_RandomFloat_functionName: Option[String] = Some("random") - override def feature_RandomFloat_rawSQL: Option[String] = None - override def feature_RandomInt_rawSQL(a: String, b: String): String = s"floor(random() * ($b - $a + 1) + $a)::integer" + override def feature_RandomFloat_rawSQL: Option[SqlSnippet] = None + override def feature_RandomInt_rawSQL: SqlSnippet = + val a = ("a", Precedence.Additive) + val b = ("b", Precedence.Additive) + SqlSnippet(Precedence.Unary, snippet"floor(random() * ($b - $a + 1) + $a)::integer") override def stringPositionFindingVia: String = "POSITION" given RandomFloat = new RandomFloat {} given RandomUUID = new RandomUUID {} - // TODO now that we have precedence, fix the parenthesization rules for this! given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} given ReversibleStrings = new ReversibleStrings {} diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index 15fe1f3..df47132 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -209,9 +209,6 @@ object Expr: case class FunctionCall1[A1, R, S1 <: ExprShape](name: String, $a1: Expr[A1, S1])(using ResultTag[R]) extends Expr[R, S1] case class FunctionCall2[A1, A2, R, S1 <: ExprShape, S2 <: ExprShape](name: String, $a1: Expr[A1, S1], $a2: Expr[A2, S2])(using ResultTag[R]) extends Expr[R, CalculatedShape[S1, S2]] - // TODO think about it again - case class RawSQLInsert[R](sql: String, replacements: Map[String, Expr[?, ?]] = Map.empty)(using ResultTag[R]) extends Expr[R, NonScalarExpr] // XXX TODO NonScalarExpr? - case class Plus[S1 <: ExprShape, S2 <: ExprShape, T: Numeric]($x: Expr[T, S1], $y: Expr[T, S2])(using ResultTag[T]) extends Expr[T, CalculatedShape[S1, S2]] case class Minus[S1 <: ExprShape, S2 <: ExprShape, T: Numeric]($x: Expr[T, S1], $y: Expr[T, S2])(using ResultTag[T]) extends Expr[T, CalculatedShape[S1, S2]] case class Times[S1 <: ExprShape, S2 <: ExprShape, T: Numeric]($x: Expr[T, S1], $y: Expr[T, S2])(using ResultTag[T]) extends Expr[T, CalculatedShape[S1, S2]] diff --git a/src/main/scala/tyql/ir/QueryIRNode.scala b/src/main/scala/tyql/ir/QueryIRNode.scala index bf3b103..c7a221c 100644 --- a/src/main/scala/tyql/ir/QueryIRNode.scala +++ b/src/main/scala/tyql/ir/QueryIRNode.scala @@ -85,9 +85,19 @@ case class SimpleCaseOp(expr: QueryIRNode, whenClauses: Seq[(QueryIRNode, QueryI val elseStr = elseClause.map(e => s" ELSE ${e.toSQLString()}").getOrElse("") s"CASE $exprStr $whenStr$elseStr END" -case class RawSQLInsertOp(sql: String, replacements: Map[String, QueryIRNode], override val precedence: Int, ast: Expr[?, ?]) extends QueryIRNode: +case class RawSQLInsertOp(snippet: SqlSnippet, replacements: Map[String, QueryIRNode], override val precedence: Int, ast: Expr[?, ?]) extends QueryIRNode: override def computeSQLString(using d: Dialect)(using cnf: Config)(): String = - replacements.foldLeft(sql) { case (acc, (k, v)) => acc.replace(k, v.toSQLString()) } + assert(replacements.keySet == snippet.sql.filter{ case (s: String) => false ; case (name: String, prec: Int) => true }.map{ case (name: String, prec: Int) => name ; case _ => assert(false) }.toSet) + assert(precedence == snippet.precedence) + snippet.sql.map { + case s: String => s + case (name: String, placementPrecedence: Int) => + val innerPrecedence = replacements(name).precedence + if innerPrecedence <= placementPrecedence then + s"(${replacements(name).toSQLString()})" + else + replacements(name).toSQLString() + }.mkString /** * Project clause, e.g. SELECT <...> FROM diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index 7d8c8e4..081cdec 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -356,22 +356,19 @@ object QueryIRTree: case f0: Expr.FunctionCall0[?] => FunctionCallOp(f0.name, Seq(), f0) case f1: Expr.FunctionCall1[?, ?, ?] => FunctionCallOp(f1.name, Seq(generateExpr(f1.$a1, symbols)), f1) case f2: Expr.FunctionCall2[?, ?, ?, ?, ?] => FunctionCallOp(f2.name, Seq(f2.$a1, f2.$a1).map(generateExpr(_, symbols)), f2) - case r: Expr.RawSQLInsert[?] => RawSQLInsertOp(r.sql, r.replacements.mapValues(generateExpr(_, symbols)).toMap, Precedence.Default, r) // TODO precedence? case u: Expr.RandomUUID => FunctionCallOp(d.feature_RandomUUID_functionName, Seq(), u) case f: Expr.RandomFloat => assert(d.feature_RandomFloat_functionName.isDefined != d.feature_RandomFloat_rawSQL.isDefined, "RandomFloat dialect feature must have either a function name or raw SQL") if d.feature_RandomFloat_functionName.isDefined then FunctionCallOp(d.feature_RandomFloat_functionName.get, Seq(), f) else - RawSQLInsertOp(d.feature_RandomFloat_rawSQL.get, Map(), Precedence.Default, f) // TODO better precedence here + RawSQLInsertOp(d.feature_RandomFloat_rawSQL.get, Map(), d.feature_RandomFloat_rawSQL.get.precedence, f) case i: Expr.RandomInt[?, ?] => - val aStr = "A82139520369" - val bStr = "B27604933360" RawSQLInsertOp( - d.feature_RandomInt_rawSQL(aStr, bStr), - Map(aStr -> generateExpr(i.$x, symbols), bStr -> generateExpr(i.$y, symbols)), - Precedence.Unary, - i) // TODO better precedence here + d.feature_RandomInt_rawSQL, + Map("a" -> generateExpr(i.$x, symbols), "b" -> generateExpr(i.$y, symbols)), + d.feature_RandomInt_rawSQL.precedence, + i) case a: Expr.Plus[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l + $r", Precedence.Additive, a) case a: Expr.Minus[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l - $r", Precedence.Additive, a) case a: Expr.Times[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l * $r", Precedence.Multiplicative, a) @@ -396,7 +393,7 @@ object QueryIRTree: ) case l: Expr.DoubleLit => Literal(s"${l.$value}", l) case l: Expr.IntLit => Literal(s"${l.$value}", l) - case l: Expr.StringLit => Literal(d.quoteStringLiteral(l.$value, insideLikePattern=false), l) // TODO fix this for LIKE patterns + case l: Expr.StringLit => Literal(d.quoteStringLiteral(l.$value, insideLikePattern=false), l) case l: Expr.BooleanLit => Literal(d.quoteBooleanLiteral(l.$value), l) case c: Expr.SearchedCase[?, ?, ?] => SearchedCaseOp(c.$cases.map(w => (generateExpr(w._1, symbols), generateExpr(w._2, symbols))), c.$else.map(generateExpr(_, symbols)), c) case c: Expr.SimpleCase[?, ?, ?, ?] => SimpleCaseOp(generateExpr(c.$expr, symbols), c.$cases.map(w => (generateExpr(w._1, symbols), generateExpr(w._2, symbols))), c.$else.map(generateExpr(_, symbols)), c) @@ -425,34 +422,33 @@ object QueryIRTree: if !d.needsStringRepeatPolyfill then FunctionCallOp("REPEAT", Seq(generateExpr(l.$s, symbols), generateExpr(l.$n, symbols)), l) else - val strStr = "STR1370031" - val numStr = "NUM6221709" - RawSQLInsertOp(s"SUBSTR(REPLACE(PRINTF('%.*c', $numStr, 'x'), 'x', $strStr), 1, length($strStr)*$numStr)", - Map(strStr -> generateExpr(l.$s, symbols), numStr -> generateExpr(l.$n, symbols)), + val str = ("str", Precedence.Concat) + val num = ("num", Precedence.Multiplicative) + RawSQLInsertOp(SqlSnippet(Precedence.Unary, snippet"SUBSTR(REPLACE(PRINTF('%.*c', $num, 'x'), 'x', $str), 1, length($str)*$num)"), + Map(str._1 -> generateExpr(l.$s, symbols), num._1 -> generateExpr(l.$n, symbols)), Precedence.Unary, l) case l: Expr.StrLPad[?, ?] => if !d.needsStringLPadRPadPolyfill then FunctionCallOp("LPAD", Seq(generateExpr(l.$s, symbols), generateExpr(l.$len, symbols), generateExpr(l.$pad, symbols)), l) else - val strStr = "STR3056960" - val padStr = "PAD2086613" - val numStr = "NUM1932354" - RawSQLInsertOp(s"substr(substr(replace(hex(zeroblob(NUM1932354)), '00', PAD2086613), 1, NUM1932354 - length(STR3056960)) || STR3056960, 1, NUM1932354)", - Map("STR3056960" -> generateExpr(l.$s, symbols), "PAD2086613" -> generateExpr(l.$pad, symbols), "NUM1932354" -> generateExpr(l.$len, symbols)), - Precedence.Additive, + val str = ("str", Precedence.Additive) + val pad = ("pad", Precedence.Concat) + val num = ("num", Precedence.Additive) + RawSQLInsertOp(SqlSnippet(Precedence.Unary, snippet"substr(substr(replace(hex(zeroblob($num)), '00', $pad), 1, $num - length($str)) || $str, 1, $num)"), + Map(str._1 -> generateExpr(l.$s, symbols), pad._1 -> generateExpr(l.$pad, symbols), num._1 -> generateExpr(l.$len, symbols)), + Precedence.Unary, l) - // TODO check if this string replacement trick is nestable, it should be... case l: Expr.StrRPad[?, ?] => if !d.needsStringLPadRPadPolyfill then FunctionCallOp("RPAD", Seq(generateExpr(l.$s, symbols), generateExpr(l.$len, symbols), generateExpr(l.$pad, symbols)), l) else - val strStr = "STR6156335" - val padStr = "PAD250762" - val numStr = "NUM7165461" - RawSQLInsertOp(s"substr(STR6156335 || substr(replace(hex(zeroblob(NUM7165461)), '00', PAD250762), 1, NUM7165461 - length(STR6156335)), 1, NUM7165461)", - Map("STR6156335" -> generateExpr(l.$s, symbols), "PAD250762" -> generateExpr(l.$pad, symbols), "NUM7165461" -> generateExpr(l.$len, symbols)), - Precedence.Additive, + val str = ("str", Precedence.Additive) + val pad = ("pad", Precedence.Concat) + val num = ("num", Precedence.Additive) + RawSQLInsertOp(SqlSnippet(Precedence.Unary, snippet"substr($str || substr(replace(hex(zeroblob($num)), '00', $pad), 1, $num - length($str)), 1, $num)"), + Map(str._1 -> generateExpr(l.$s, symbols), pad._1 -> generateExpr(l.$pad, symbols), num._1 -> generateExpr(l.$len, symbols)), + Precedence.Unary, l) case l: Expr.StrPositionIn[?, ?] => d.stringPositionFindingVia match case "POSITION" => BinExprOp(generateExpr(l.$substr, symbols), generateExpr(l.$string, symbols), (l, r) => s"POSITION($l IN $r)", Precedence.Unary, l) diff --git a/src/main/scala/tyql/snippet.scala b/src/main/scala/tyql/snippet.scala new file mode 100644 index 0000000..0bab006 --- /dev/null +++ b/src/main/scala/tyql/snippet.scala @@ -0,0 +1,22 @@ +package tyql + +private type Precedence = Int +private type SnippetSpecificationPart = (String | (String, Precedence)) + +case class SqlSnippet(precedence: Precedence, sql: Seq[SnippetSpecificationPart]) + +extension (sc: StringContext) { + def snippet(args: (String, Precedence)*): Seq[SnippetSpecificationPart] = { + val parts = sc.parts.iterator + val expressions = args.iterator + val buf = Seq.newBuilder[SnippetSpecificationPart] + while (parts.hasNext) { + buf += parts.next() + if (expressions.hasNext) { + val (expr, prec) = expressions.next() + buf += ((expr, prec)) + } + } + buf.result() + } +} diff --git a/src/test/scala/test/integration/random.scala b/src/test/scala/test/integration/random.scala index f74fc50..cf6cc89 100644 --- a/src/test/scala/test/integration/random.scala +++ b/src/test/scala/test/integration/random.scala @@ -3,7 +3,7 @@ package test.integration.random import munit.FunSuite import test.{withDB, checkExprDialect} import java.sql.{Connection, Statement, ResultSet} -import tyql.{Dialect, Table, Expr} +import tyql.{Dialect, Table, Expr, lit} class RandomTests extends FunSuite { test("randomFloat test") { @@ -129,33 +129,33 @@ class RandomTests extends FunSuite { { import Dialect.postgresql.given - checkExprDialect[Int](Expr.randomInt(0, 2), checkValue)(withDB.postgres) - checkExprDialect[Int](Expr.randomInt(44, 44), checkInclusion)(withDB.postgres) + checkExprDialect[Int](Expr.randomInt(0, lit(1) + lit(1)), checkValue)(withDB.postgres) + checkExprDialect[Int](Expr.randomInt(lit(20) + lit(24), 44), checkInclusion)(withDB.postgres) } { import Dialect.mysql.given - checkExprDialect[Int](Expr.randomInt(tyql.lit(0), 2), checkValue)(withDB.mysql) - checkExprDialect[Int](Expr.randomInt(44, 44), checkInclusion)(withDB.mysql) + checkExprDialect[Int](Expr.randomInt(tyql.lit(0), lit(1) + lit(1)), checkValue)(withDB.mysql) + checkExprDialect[Int](Expr.randomInt(lit(22) + lit(22), 44), checkInclusion)(withDB.mysql) } { import Dialect.mariadb.given - checkExprDialect[Int](Expr.randomInt(0, 2), checkValue)(withDB.mariadb) - checkExprDialect[Int](Expr.randomInt(44, 44), checkInclusion)(withDB.mariadb) + checkExprDialect[Int](Expr.randomInt(0, lit(1) + lit(1)), checkValue)(withDB.mariadb) + checkExprDialect[Int](Expr.randomInt(lit(20) + lit(24), 44), checkInclusion)(withDB.mariadb) } { import Dialect.duckdb.given - checkExprDialect[Int](Expr.randomInt(0, 2), checkValue)(withDB.duckdb) - checkExprDialect[Int](Expr.randomInt(44, tyql.lit(44)), checkInclusion)(withDB.duckdb) + checkExprDialect[Int](Expr.randomInt(0, lit(1) + lit(1)), checkValue)(withDB.duckdb) + checkExprDialect[Int](Expr.randomInt(lit(20) + lit(24), tyql.lit(44)), checkInclusion)(withDB.duckdb) } { import Dialect.h2.given - checkExprDialect[Int](Expr.randomInt(0, 2), checkValue)(withDB.h2) - checkExprDialect[Int](Expr.randomInt(44, 44), checkInclusion)(withDB.h2) + checkExprDialect[Int](Expr.randomInt(0, lit(1) + lit(1)), checkValue)(withDB.h2) + checkExprDialect[Int](Expr.randomInt(lit(22) + lit(22), 44), checkInclusion)(withDB.h2) } { import Dialect.sqlite.given - checkExprDialect[Int](Expr.randomInt(0, 2), checkValue)(withDB.sqlite) - checkExprDialect[Int](Expr.randomInt(44, 44), checkInclusion)(withDB.sqlite) + checkExprDialect[Int](Expr.randomInt(0, lit(1) + lit(1)), checkValue)(withDB.sqlite) + checkExprDialect[Int](Expr.randomInt(lit(20) + lit(24), 44), checkInclusion)(withDB.sqlite) } } From 5dede044c7207c9f9548430d9b2b565fbf70a8c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Fri, 22 Nov 2024 15:52:18 +0100 Subject: [PATCH 073/106] do not repeat the parameters in macro-like snippets --- src/main/scala/tyql/dialects/dialects.scala | 30 ++++++++++----------- src/main/scala/tyql/ir/QueryIRTree.scala | 16 +++++------ 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index 1094b98..4d15a3f 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -60,9 +60,9 @@ object Dialect: override def feature_RandomFloat_functionName: Option[String] = Some("random") override def feature_RandomFloat_rawSQL: Option[SqlSnippet] = None override def feature_RandomInt_rawSQL: SqlSnippet = - val a = ("a", Precedence.Additive) - val b = ("b", Precedence.Additive) - SqlSnippet(Precedence.Unary, snippet"floor(random() * ($b - $a + 1) + $a)::integer") + val a = ("a", Precedence.Concat) + val b = ("b", Precedence.Concat) + SqlSnippet(Precedence.Unary, snippet"(with randomIntParameters as (select $a as a, $b as b) select floor(random() * (b - a + 1) + a)::integer from randomIntParameters)") override def stringPositionFindingVia: String = "POSITION" given RandomFloat = new RandomFloat {} @@ -83,9 +83,9 @@ object Dialect: override def feature_RandomFloat_functionName: Option[String] = Some("rand") override def feature_RandomFloat_rawSQL: Option[SqlSnippet] = None override def feature_RandomInt_rawSQL: SqlSnippet = - val a = ("a", Precedence.Additive) - val b = ("b", Precedence.Additive) - SqlSnippet(Precedence.Unary, snippet"floor(rand() * ($b - $a + 1) + $a)") + val a = ("a", Precedence.Concat) + val b = ("b", Precedence.Concat) + SqlSnippet(Precedence.Unary, snippet"(with randomIntParameters as (select $a as a, $b as b) select floor(rand() * (b - a + 1) + a) from randomIntParameters)") given RandomFloat = new RandomFloat {} given RandomUUID = new RandomUUID {} @@ -114,9 +114,9 @@ object Dialect: override def feature_RandomFloat_functionName: Option[String] = None override def feature_RandomFloat_rawSQL: Option[SqlSnippet] = Some(SqlSnippet(Precedence.Unary, snippet"(0.5 - RANDOM() / CAST(-9223372036854775808 AS REAL) / 2)")) override def feature_RandomInt_rawSQL: SqlSnippet = - val a = ("a", Precedence.Additive) - val b = ("b", Precedence.Additive) - SqlSnippet(Precedence.Unary, snippet"cast(abs(random() % ($b - $a + 1) + $a) as integer)") + val a = ("a", Precedence.Concat) + val b = ("b", Precedence.Concat) + SqlSnippet(Precedence.Unary, snippet"(with randomIntParameters as (select $a as a, $b as b) select cast(abs(random() % (b - a + 1) + a) as integer) from randomIntParameters)") override def needsStringRepeatPolyfill: Boolean = true override def needsStringLPadRPadPolyfill: Boolean = true override def stringPositionFindingVia: String = "INSTR" @@ -137,9 +137,9 @@ object Dialect: override def feature_RandomFloat_functionName: Option[String] = Some("rand") override def feature_RandomFloat_rawSQL: Option[SqlSnippet] = None override def feature_RandomInt_rawSQL: SqlSnippet = - val a = ("a", Precedence.Additive) - val b = ("b", Precedence.Additive) - SqlSnippet(Precedence.Unary, snippet"floor(rand() * ($b - $a + 1) + $a)") + val a = ("a", Precedence.Concat) + val b = ("b", Precedence.Concat) + SqlSnippet(Precedence.Unary, snippet"(with randomIntParameters as (select $a as a, $b as b) select floor(rand() * (b - a + 1) + a) from randomIntParameters)") given RandomFloat = new RandomFloat {} given RandomUUID = new RandomUUID {} @@ -158,9 +158,9 @@ object Dialect: override def feature_RandomFloat_functionName: Option[String] = Some("random") override def feature_RandomFloat_rawSQL: Option[SqlSnippet] = None override def feature_RandomInt_rawSQL: SqlSnippet = - val a = ("a", Precedence.Additive) - val b = ("b", Precedence.Additive) - SqlSnippet(Precedence.Unary, snippet"floor(random() * ($b - $a + 1) + $a)::integer") + val a = ("a", Precedence.Concat) + val b = ("b", Precedence.Concat) + SqlSnippet(Precedence.Unary, snippet"(with randomIntParameters as (select $a as a, $b as b) select floor(random() * (b - a + 1) + a)::integer from randomIntParameters)") override def stringPositionFindingVia: String = "POSITION" given RandomFloat = new RandomFloat {} diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index 081cdec..6544148 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -423,8 +423,8 @@ object QueryIRTree: FunctionCallOp("REPEAT", Seq(generateExpr(l.$s, symbols), generateExpr(l.$n, symbols)), l) else val str = ("str", Precedence.Concat) - val num = ("num", Precedence.Multiplicative) - RawSQLInsertOp(SqlSnippet(Precedence.Unary, snippet"SUBSTR(REPLACE(PRINTF('%.*c', $num, 'x'), 'x', $str), 1, length($str)*$num)"), + val num = ("num", Precedence.Concat) + RawSQLInsertOp(SqlSnippet(Precedence.Unary, snippet"(with stringRepeatParameters as (select $str as str, $num as num) select SUBSTR(REPLACE(PRINTF('%.*c', num, 'x'), 'x', str), 1, length(str)*num) from stringRepeatParameters)"), Map(str._1 -> generateExpr(l.$s, symbols), num._1 -> generateExpr(l.$n, symbols)), Precedence.Unary, l) @@ -432,10 +432,10 @@ object QueryIRTree: if !d.needsStringLPadRPadPolyfill then FunctionCallOp("LPAD", Seq(generateExpr(l.$s, symbols), generateExpr(l.$len, symbols), generateExpr(l.$pad, symbols)), l) else - val str = ("str", Precedence.Additive) + val str = ("str", Precedence.Concat) val pad = ("pad", Precedence.Concat) - val num = ("num", Precedence.Additive) - RawSQLInsertOp(SqlSnippet(Precedence.Unary, snippet"substr(substr(replace(hex(zeroblob($num)), '00', $pad), 1, $num - length($str)) || $str, 1, $num)"), + val num = ("num", Precedence.Concat) + RawSQLInsertOp(SqlSnippet(Precedence.Unary, snippet"(with lpadParameters as (select $str as str, $pad as pad, $num as num) select substr(substr(replace(hex(zeroblob(num)), '00', pad), 1, num - length(str)) || str, 1, num) from lpadParameters)"), Map(str._1 -> generateExpr(l.$s, symbols), pad._1 -> generateExpr(l.$pad, symbols), num._1 -> generateExpr(l.$len, symbols)), Precedence.Unary, l) @@ -443,10 +443,10 @@ object QueryIRTree: if !d.needsStringLPadRPadPolyfill then FunctionCallOp("RPAD", Seq(generateExpr(l.$s, symbols), generateExpr(l.$len, symbols), generateExpr(l.$pad, symbols)), l) else - val str = ("str", Precedence.Additive) + val str = ("str", Precedence.Concat) val pad = ("pad", Precedence.Concat) - val num = ("num", Precedence.Additive) - RawSQLInsertOp(SqlSnippet(Precedence.Unary, snippet"substr($str || substr(replace(hex(zeroblob($num)), '00', $pad), 1, $num - length($str)), 1, $num)"), + val num = ("num", Precedence.Concat) + RawSQLInsertOp(SqlSnippet(Precedence.Unary, snippet"(with rpadParameters as (select $str as str, $pad as pad, $num as num) select substr(str || substr(replace(hex(zeroblob(num)), '00', pad), 1, num - length(str)), 1, num) from rpadParameters)"), Map(str._1 -> generateExpr(l.$s, symbols), pad._1 -> generateExpr(l.$pad, symbols), num._1 -> generateExpr(l.$len, symbols)), Precedence.Unary, l) From f9e6ffcfad78522023cf9230a98afa3fde6a73ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sat, 23 Nov 2024 13:31:41 +0100 Subject: [PATCH 074/106] RandomFloat is everywhere, no need to be a feature --- src/main/scala/tyql/dialects/dialect features.scala | 1 - src/main/scala/tyql/dialects/dialects.scala | 10 ++-------- src/main/scala/tyql/expr/Expr.scala | 2 +- src/test/scala/test/integration/random.scala | 6 ++---- 4 files changed, 5 insertions(+), 14 deletions(-) diff --git a/src/main/scala/tyql/dialects/dialect features.scala b/src/main/scala/tyql/dialects/dialect features.scala index 5923d96..a4342be 100644 --- a/src/main/scala/tyql/dialects/dialect features.scala +++ b/src/main/scala/tyql/dialects/dialect features.scala @@ -3,7 +3,6 @@ package tyql trait DialectFeature object DialectFeature: - trait RandomFloat extends DialectFeature trait RandomUUID extends DialectFeature trait RandomIntegerInInclusiveRange extends DialectFeature diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index 4d15a3f..c31f058 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -25,8 +25,8 @@ trait Dialect: val xorOperatorSupportedNatively = false def feature_RandomUUID_functionName: String = unsupportedFeature("RandomUUID") - def feature_RandomFloat_functionName: Option[String] = unsupportedFeature("RandomFloat") - def feature_RandomFloat_rawSQL: Option[SqlSnippet] = unsupportedFeature("RandomFloat") + def feature_RandomFloat_functionName: Option[String] = throw new UnsupportedOperationException("RandomFloat") + def feature_RandomFloat_rawSQL: Option[SqlSnippet] = throw new UnsupportedOperationException("RandomFloat") def feature_RandomInt_rawSQL: SqlSnippet = unsupportedFeature("RandomInt") def needsStringRepeatPolyfill: Boolean = false @@ -65,7 +65,6 @@ object Dialect: SqlSnippet(Precedence.Unary, snippet"(with randomIntParameters as (select $a as a, $b as b) select floor(random() * (b - a + 1) + a)::integer from randomIntParameters)") override def stringPositionFindingVia: String = "POSITION" - given RandomFloat = new RandomFloat {} given RandomUUID = new RandomUUID {} given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} given ReversibleStrings = new ReversibleStrings {} @@ -87,7 +86,6 @@ object Dialect: val b = ("b", Precedence.Concat) SqlSnippet(Precedence.Unary, snippet"(with randomIntParameters as (select $a as a, $b as b) select floor(rand() * (b - a + 1) + a) from randomIntParameters)") - given RandomFloat = new RandomFloat {} given RandomUUID = new RandomUUID {} given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} given ReversibleStrings = new ReversibleStrings {} @@ -98,7 +96,6 @@ object Dialect: given Dialect = new mysql.MySQLDialect with QuotingIdentifiers.MariadbBehavior: override def name() = "MariaDB Dialect" - given RandomFloat = mysql.given_RandomFloat given RandomUUID = mysql.given_RandomUUID given RandomIntegerInInclusiveRange = mysql.given_RandomIntegerInInclusiveRange given ReversibleStrings = mysql.given_ReversibleStrings @@ -121,7 +118,6 @@ object Dialect: override def needsStringLPadRPadPolyfill: Boolean = true override def stringPositionFindingVia: String = "INSTR" - given RandomFloat = new RandomFloat {} given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} given ReversibleStrings = new ReversibleStrings {} @@ -141,7 +137,6 @@ object Dialect: val b = ("b", Precedence.Concat) SqlSnippet(Precedence.Unary, snippet"(with randomIntParameters as (select $a as a, $b as b) select floor(rand() * (b - a + 1) + a) from randomIntParameters)") - given RandomFloat = new RandomFloat {} given RandomUUID = new RandomUUID {} given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} @@ -163,7 +158,6 @@ object Dialect: SqlSnippet(Precedence.Unary, snippet"(with randomIntParameters as (select $a as a, $b as b) select floor(random() * (b - a + 1) + a)::integer from randomIntParameters)") override def stringPositionFindingVia: String = "POSITION" - given RandomFloat = new RandomFloat {} given RandomUUID = new RandomUUID {} given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} given ReversibleStrings = new ReversibleStrings {} diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index df47132..9ab9190 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -311,7 +311,7 @@ object Expr: // given Conversion[Boolean, BooleanLit] = BooleanLit(_) // TODO why does this break things? - def randomFloat(using r: DialectFeature.RandomFloat)(): Expr[Double, NonScalarExpr] = + def randomFloat(): Expr[Double, NonScalarExpr] = RandomFloat() def randomUUID(using r: DialectFeature.RandomUUID)(): Expr[String, NonScalarExpr] = diff --git a/src/test/scala/test/integration/random.scala b/src/test/scala/test/integration/random.scala index cf6cc89..fd6b275 100644 --- a/src/test/scala/test/integration/random.scala +++ b/src/test/scala/test/integration/random.scala @@ -46,10 +46,6 @@ class RandomTests extends FunSuite { var q: tyql.Expr[Double, tyql.NonScalarExpr] = null { - // XXX you can program against a feature set, not any specific dialect! - // TODO but the syntax for now is ugly... - import tyql.DialectFeature.RandomFloat - given RandomFloat = new RandomFloat {} q = Expr.randomFloat() } @@ -97,6 +93,8 @@ class RandomTests extends FunSuite { assert(r.matches("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")) } + // XXX you can program against a feature set, not any specific dialect! + // TODO but the syntax for now is ugly... var q: tyql.Expr[String, tyql.NonScalarExpr] = null { import tyql.DialectFeature.RandomUUID From b0595e6f35e3d24fb0e121ac9c2bc7350ffb1ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sat, 23 Nov 2024 14:25:27 +0100 Subject: [PATCH 075/106] first draft of documentation generation --- tooling/generate documentation.scala | 124 +++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 tooling/generate documentation.scala diff --git a/tooling/generate documentation.scala b/tooling/generate documentation.scala new file mode 100644 index 0000000..c8cfb6c --- /dev/null +++ b/tooling/generate documentation.scala @@ -0,0 +1,124 @@ +//> using jvm "21" +//> using options "-experimental" +//> using dep "ch.epfl.lamp::tyql:0.0.1" +//> using dep "com.lihaoyi::os-lib-watch:0.11.3" +//> using dep "com.lihaoyi::pprint:0.9.0" + +import pprint.pprintln + + +/* + For now we are not yet writing the exact documentation/referece/tutorial, + but we are building the infrastructure for generating it. + + TODO: + - have the snippets return some values that can be read back + - have the snippets return SQL + - have the snippets return the results of running the SQL against the DB + - tracking of polyfills + - generate markdown + - generate HTML +*/ + +/// sudo mkdir /mnt/ramdisk +/// sudo mount -t tmpfs -o size=1g tmpfs /mnt/ramdisk +val directory = "/mnt/ramdisk/" + +object dialects { + val postgresql = "postgresql" + val mysql = "mysql" + val mariadb = "mariadb" + val sqlite = "sqlite" + val duckdb = "duckdb" + val h2 = "h2" +} + +object dialect_features { + val randomUUID = "randomUUID" + val randomInt = "randomInt" + val reversibleStrings = "reversibleStrings" +} + +val dialect_imports = Map( + dialects.postgresql -> "import tyql.Dialect.postgresql.given", + dialects.mysql -> "import tyql.Dialect.mysql.given", + dialects.mariadb -> "import tyql.Dialect.mariadb.given", + dialects.sqlite -> "import tyql.Dialect.sqlite.given", + dialects.duckdb -> "import tyql.Dialect.duckdb.given", + dialects.h2 -> "import tyql.Dialect.h2.given" +) + +val dialect_features_uses = Map( + dialect_features.randomUUID -> "tyql.Expr.randomUUID()", + dialect_features.randomInt -> "tyql.Expr.randomInt(0, tyql.lit(1) + tyql.lit(1))", + dialect_features.reversibleStrings -> "tyql.Expr.reverse(tyql.lit(\"abc\"))" +) + +// WARNING XXX: this needs `sbt publishLocal` first +def testScript(dialectSnippet: String, codeSnippet: String) = "" + +s"""//> using jvm "21" +//> using options "-experimental" +//> using dep "ch.epfl.lamp::tyql:0.0.1" + +import tyql.* + +$dialectSnippet + +@main def main() = { + try { + + $codeSnippet + + } catch { + case _ => println("exception") + } +} +""" + +@main def main() = { + val NullOutput = os.ProcessOutput((_, _) => ()) + val matrix = dialect_features_uses.map { case (feature, codeSnippet) => + val dialectResults = dialect_imports.map { case (dialect, dialectSnippet) => + val script = testScript(dialectSnippet, codeSnippet) + val path = s"${directory}main.scala" + os.write.over(os.Path(path), script) + val compilationAndRunStatus = + os.proc("scala-cli", path) + .call(cwd=os.Path(directory), check=false, stdout=NullOutput, stderr=NullOutput) + dialect -> (compilationAndRunStatus.exitCode == 0) + }.toMap + + feature -> dialectResults + } + + pprintln(matrix) +} + +/* +Map( + "randomUUID" -> HashMap( + "sqlite" -> false, + "postgresql" -> true, + "h2" -> true, + "mariadb" -> true, + "mysql" -> true, + "duckdb" -> true + ), + "randomInt" -> HashMap( + "sqlite" -> true, + "postgresql" -> true, + "h2" -> true, + "mariadb" -> true, + "mysql" -> true, + "duckdb" -> true + ), + "reversibleStrings" -> HashMap( + "sqlite" -> true, + "postgresql" -> true, + "h2" -> false, + "mariadb" -> true, + "mysql" -> true, + "duckdb" -> true + ) +) +*/ From cc0caca5c2627b73cb8317b0a5099189d745ee1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sun, 24 Nov 2024 16:02:51 +0100 Subject: [PATCH 076/106] more case conversions handled --- src/main/scala/tyql/config.scala | 16 ++++++--- .../scala/test/config/case convention.scala | 34 ++++++++++++------- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/src/main/scala/tyql/config.scala b/src/main/scala/tyql/config.scala index 8e10f1e..e7f548d 100644 --- a/src/main/scala/tyql/config.scala +++ b/src/main/scala/tyql/config.scala @@ -1,13 +1,18 @@ package tyql -// TODO Quill also allows you to chain a few of them and offers uppercase and lowercase options +// Quill also allows you to chain a few of them and offers uppercase and lowercase options +// but I do think that is overengineered. Also, Quill handles identifier escaping here, +// which is just incorrect and asking for trouble. // https://github.com/fwbrasil/quill/#naming-strategy enum CaseConvention: case Exact - case Underscores // three_letter_word - case PascalCase // ThreeLetterWord - case CamelCase // threeLetterWord + case Underscores // three_letter_word + case PascalCase // ThreeLetterWord + case CamelCase // threeLetterWord + case CapitalUnderscores // THREE_LETTER_WORD + case Joined // threeletterword + case JoinedCapital // THREELETTERWORD def convert(name: String): String = val parts = CaseConvention.splitName(name) @@ -16,6 +21,9 @@ enum CaseConvention: case Underscores => parts.mkString("_") case PascalCase => parts.map(_.capitalize).mkString case CamelCase => parts.head + parts.tail.map(_.capitalize).mkString + case CapitalUnderscores => parts.map(_.toUpperCase).mkString("_") + case Joined => parts.mkString + case JoinedCapital => parts.map(_.toUpperCase).mkString object CaseConvention: private def splitName(name: String): List[String] = diff --git a/src/test/scala/test/config/case convention.scala b/src/test/scala/test/config/case convention.scala index 47029d5..0e77891 100644 --- a/src/test/scala/test/config/case convention.scala +++ b/src/test/scala/test/config/case convention.scala @@ -12,23 +12,26 @@ import NamedTuple.{NamedTuple, AnyNamedTuple} class CaseConventionTests extends FunSuite { private val expectations = Seq( - ("aa bb cc", "aa_bb_cc", "aaBbCc", "AaBbCc"), - ("aabbcc", "aabbcc", "aabbcc", "Aabbcc"), - ("aaBb_cc", "aa_bb_cc", "aaBbCc", "AaBbCc"), - ("AaBbCc", "aa_bb_cc", "aaBbCc", "AaBbCc"), - ("abc12", "abc12", "abc12", "Abc12"), - ("abc_12", "abc_12", "abc12", "Abc12"), - ("abC12", "ab_c12", "abC12", "AbC12"), - ("ABC", "a_b_c", "aBC", "ABC"), + ("aa bb cc", "aa_bb_cc", "aaBbCc", "AaBbCc", "AA_BB_CC", "aabbcc", "AABBCC"), + ("aabbcc", "aabbcc", "aabbcc", "Aabbcc", "AABBCC", "aabbcc", "AABBCC"), + ("aaBb_cc", "aa_bb_cc", "aaBbCc", "AaBbCc", "AA_BB_CC", "aabbcc", "AABBCC"), + ("AaBbCc", "aa_bb_cc", "aaBbCc", "AaBbCc", "AA_BB_CC", "aabbcc", "AABBCC"), + ("abc12", "abc12", "abc12", "Abc12", "ABC12", "abc12", "ABC12"), + ("abc_12", "abc_12", "abc12", "Abc12", "ABC_12", "abc12", "ABC12"), + ("abC12", "ab_c12", "abC12", "AbC12", "AB_C12", "abc12", "ABC12"), + ("ABC", "a_b_c", "aBC", "ABC", "A_B_C", "abc", "ABC"), ) test("expected case conversions") { for (e <- expectations) { - val (in, underscores, camelCase, pascalCase) = e + val (in, underscores, camelCase, pascalCase, capitalUnderscores, joined, joinedCapital) = e assertEquals(CaseConvention.Exact.convert(in), in) assertEquals(CaseConvention.Underscores.convert(in), underscores) assertEquals(CaseConvention.CamelCase.convert(in), camelCase) assertEquals(CaseConvention.PascalCase.convert(in), pascalCase) + assertEquals(CaseConvention.CapitalUnderscores.convert(in), capitalUnderscores) + assertEquals(CaseConvention.Joined.convert(in), joined) + assertEquals(CaseConvention.JoinedCapital.convert(in), joinedCapital) } } @@ -52,9 +55,9 @@ class CaseConventionTests extends FunSuite { test("postgres handles it".tag(needsDBs)) { withDB.postgres{ conn => - def check(tableName: String, columnName: String)(using cnf: Config) = { - val escapedTableName = summon[Dialect].quoteIdentifier(tableName) - val escapedColunmName = summon[Dialect].quoteIdentifier(columnName) + def check(tableName: String, columnName: String, postgresTableName: String = null, postgresColumnName: String = null)(using cnf: Config) = { + val escapedTableName = summon[Dialect].quoteIdentifier(Option(postgresTableName).getOrElse(tableName)) + val escapedColunmName = summon[Dialect].quoteIdentifier(Option(postgresColumnName).getOrElse(columnName)) case class Tbl(id: Int, aaBb_Cc: String) val q = Table[Tbl](tableName).map(b => b.aaBb_Cc) @@ -78,6 +81,13 @@ class CaseConventionTests extends FunSuite { check("caseConventionTests19471", "aaBbCc")(using new Config(CaseConvention.CamelCase) {}) check("CaseConventionTests19471", "AaBbCc")(using new Config(CaseConvention.PascalCase) {}) check("case_convention_tests19471", "aa_bb_cc")(using new Config(CaseConvention.Underscores) {}) + check("CaseConventionTests19471", "aaBb_Cc", postgresTableName="CASE_CONVENTION_TESTS19471", postgresColumnName="AA_BB_CC")(using new Config(CaseConvention.CapitalUnderscores) {}) // XXX AA_BB_CC would be interpreted as a_a_b_c ! + check("caseconventiontests19471", "aabbcc")(using new Config(CaseConvention.Joined) {}) + check("CaseConventionTests19471", "aaBb_Cc", postgresTableName="CASECONVENTIONTESTS19471", postgresColumnName="AABBCC")(using new Config(CaseConvention.JoinedCapital) {}) // XXX AA_BB_CC would be interpreted as a_a_b_c ! + // TODO document this weird (?) behavior, or maybe change it? + // In the Scala code you must use `_`s or capital letters as separators, and the config + // changes only what is ouputted to the DB, so you cannot use something like ABC as the column name from + // the Scala code since it will be interpreted as a compound name ['a', 'b', 'c']. } } } From e329c00b46b7b415fc7cca5ba92f78c92903a687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sun, 24 Nov 2024 17:02:01 +0100 Subject: [PATCH 077/106] documentation: polyfill tracking, returning sql --- src/main/scala/tyql/dialects/dialects.scala | 3 + src/main/scala/tyql/ir/QueryIRTree.scala | 9 +- tooling/generate documentation.scala | 96 +++++++++++++++------ 3 files changed, 81 insertions(+), 27 deletions(-) diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index c31f058..22d1892 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -161,3 +161,6 @@ object Dialect: given RandomUUID = new RandomUUID {} given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} given ReversibleStrings = new ReversibleStrings {} + +// TODO I currenly have no better idea for this, maybe some macro? +var polyfillWasUsed: Function0[Unit] = () => () diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index 6544148..6c0700a 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -351,7 +351,9 @@ object QueryIRTree: case x: Expr.Xor[?] => BinExprOp(generateExpr(x.$x, symbols), generateExpr(x.$y, symbols), ((l, r) => d.xorOperatorSupportedNatively match case true => s"$l XOR $r" - case false => s"($l = TRUE) <> ($r = TRUE)" + case false => + polyfillWasUsed() + s"($l = TRUE) <> ($r = TRUE)" ), if d.xorOperatorSupportedNatively then 45 else 43, x) // TODO precedence? case f0: Expr.FunctionCall0[?] => FunctionCallOp(f0.name, Seq(), f0) case f1: Expr.FunctionCall1[?, ?, ?] => FunctionCallOp(f1.name, Seq(generateExpr(f1.$a1, symbols)), f1) @@ -362,8 +364,10 @@ object QueryIRTree: if d.feature_RandomFloat_functionName.isDefined then FunctionCallOp(d.feature_RandomFloat_functionName.get, Seq(), f) else + polyfillWasUsed() RawSQLInsertOp(d.feature_RandomFloat_rawSQL.get, Map(), d.feature_RandomFloat_rawSQL.get.precedence, f) case i: Expr.RandomInt[?, ?] => + polyfillWasUsed() RawSQLInsertOp( d.feature_RandomInt_rawSQL, Map("a" -> generateExpr(i.$x, symbols), "b" -> generateExpr(i.$y, symbols)), @@ -424,6 +428,7 @@ object QueryIRTree: else val str = ("str", Precedence.Concat) val num = ("num", Precedence.Concat) + polyfillWasUsed() RawSQLInsertOp(SqlSnippet(Precedence.Unary, snippet"(with stringRepeatParameters as (select $str as str, $num as num) select SUBSTR(REPLACE(PRINTF('%.*c', num, 'x'), 'x', str), 1, length(str)*num) from stringRepeatParameters)"), Map(str._1 -> generateExpr(l.$s, symbols), num._1 -> generateExpr(l.$n, symbols)), Precedence.Unary, @@ -435,6 +440,7 @@ object QueryIRTree: val str = ("str", Precedence.Concat) val pad = ("pad", Precedence.Concat) val num = ("num", Precedence.Concat) + polyfillWasUsed() RawSQLInsertOp(SqlSnippet(Precedence.Unary, snippet"(with lpadParameters as (select $str as str, $pad as pad, $num as num) select substr(substr(replace(hex(zeroblob(num)), '00', pad), 1, num - length(str)) || str, 1, num) from lpadParameters)"), Map(str._1 -> generateExpr(l.$s, symbols), pad._1 -> generateExpr(l.$pad, symbols), num._1 -> generateExpr(l.$len, symbols)), Precedence.Unary, @@ -446,6 +452,7 @@ object QueryIRTree: val str = ("str", Precedence.Concat) val pad = ("pad", Precedence.Concat) val num = ("num", Precedence.Concat) + polyfillWasUsed() RawSQLInsertOp(SqlSnippet(Precedence.Unary, snippet"(with rpadParameters as (select $str as str, $pad as pad, $num as num) select substr(str || substr(replace(hex(zeroblob(num)), '00', pad), 1, num - length(str)), 1, num) from rpadParameters)"), Map(str._1 -> generateExpr(l.$s, symbols), pad._1 -> generateExpr(l.$pad, symbols), num._1 -> generateExpr(l.$len, symbols)), Precedence.Unary, diff --git a/tooling/generate documentation.scala b/tooling/generate documentation.scala index c8cfb6c..50dfa8a 100644 --- a/tooling/generate documentation.scala +++ b/tooling/generate documentation.scala @@ -3,6 +3,7 @@ //> using dep "ch.epfl.lamp::tyql:0.0.1" //> using dep "com.lihaoyi::os-lib-watch:0.11.3" //> using dep "com.lihaoyi::pprint:0.9.0" +//> using dep "com.lihaoyi::ujson:4.0.2" import pprint.pprintln @@ -12,10 +13,7 @@ import pprint.pprintln but we are building the infrastructure for generating it. TODO: - - have the snippets return some values that can be read back - - have the snippets return SQL - have the snippets return the results of running the SQL against the DB - - tracking of polyfills - generate markdown - generate HTML */ @@ -59,33 +57,55 @@ def testScript(dialectSnippet: String, codeSnippet: String) = "" + s"""//> using jvm "21" //> using options "-experimental" //> using dep "ch.epfl.lamp::tyql:0.0.1" +//> using dep "com.lihaoyi::ujson:4.0.2" import tyql.* $dialectSnippet -@main def main() = { - try { +case class R(i: Int) +val t = tyql.Table[R]("t") - $codeSnippet +var wasPolyfillUsed = false +var sql = "" +var exception = false +@main def main() = { + tyql.polyfillWasUsed = () => { wasPolyfillUsed = true } + try { + sql = t.map(_ => ($codeSnippet)).toQueryIR.toSQLString() } catch { - case _ => println("exception") + case _ => exception = true + } finally { + val js = if exception then + ujson.Obj("exception" -> true) + else + ujson.Obj("exception" -> false, "sql" -> sql, "wasPolyfillUsed" -> wasPolyfillUsed) + println(ujson.write(js)) } } + """ @main def main() = { - val NullOutput = os.ProcessOutput((_, _) => ()) val matrix = dialect_features_uses.map { case (feature, codeSnippet) => + println("FEATURE " + feature) val dialectResults = dialect_imports.map { case (dialect, dialectSnippet) => + println("DIALECT " + dialect) val script = testScript(dialectSnippet, codeSnippet) val path = s"${directory}main.scala" os.write.over(os.Path(path), script) val compilationAndRunStatus = os.proc("scala-cli", path) - .call(cwd=os.Path(directory), check=false, stdout=NullOutput, stderr=NullOutput) - dialect -> (compilationAndRunStatus.exitCode == 0) + .call(cwd=os.Path(directory), check=false) + if (compilationAndRunStatus.exitCode != 0) { + dialect -> (compilationAndRunStatus.exitCode == 0) + } else { + val text = compilationAndRunStatus.out.text().trim() + val result = ujson.read(text.split("\n").last) + assert(result("exception").bool == false) + dialect -> (true, result("wasPolyfillUsed").bool, result("sql").str) + } }.toMap feature -> dialectResults @@ -98,27 +118,51 @@ $dialectSnippet Map( "randomUUID" -> HashMap( "sqlite" -> false, - "postgresql" -> true, - "h2" -> true, - "mariadb" -> true, - "mysql" -> true, - "duckdb" -> true + "postgresql" -> (true, false, "SELECT gen_random_uuid() FROM t as t0"), + "h2" -> (true, false, "SELECT RANDOM_UUID() FROM t as t0"), + "mariadb" -> (true, false, "SELECT UUID() FROM t as t0"), + "mysql" -> (true, false, "SELECT UUID() FROM t as t0"), + "duckdb" -> (true, false, "SELECT uuid() FROM t as t0") ), "randomInt" -> HashMap( - "sqlite" -> true, - "postgresql" -> true, - "h2" -> true, - "mariadb" -> true, - "mysql" -> true, - "duckdb" -> true + "sqlite" -> ( + true, + true, + "SELECT (with randomIntParameters as (select 0 as a, 1 + 1 as b) select cast(abs(random() % (b - a + 1) + a) as integer) from randomIntParameters) FROM t as t0" + ), + "postgresql" -> ( + true, + true, + "SELECT (with randomIntParameters as (select 0 as a, 1 + 1 as b) select floor(random() * (b - a + 1) + a)::integer from randomIntParameters) FROM t as t0" + ), + "h2" -> ( + true, + true, + "SELECT (with randomIntParameters as (select 0 as a, 1 + 1 as b) select floor(rand() * (b - a + 1) + a) from randomIntParameters) FROM t as t0" + ), + "mariadb" -> ( + true, + true, + "SELECT (with randomIntParameters as (select 0 as a, 1 + 1 as b) select floor(rand() * (b - a + 1) + a) from randomIntParameters) FROM t as t0" + ), + "mysql" -> ( + true, + true, + "SELECT (with randomIntParameters as (select 0 as a, 1 + 1 as b) select floor(rand() * (b - a + 1) + a) from randomIntParameters) FROM t as t0" + ), + "duckdb" -> ( + true, + true, + "SELECT (with randomIntParameters as (select 0 as a, 1 + 1 as b) select floor(random() * (b - a + 1) + a)::integer from randomIntParameters) FROM t as t0" + ) ), "reversibleStrings" -> HashMap( - "sqlite" -> true, - "postgresql" -> true, + "sqlite" -> (true, false, "SELECT REVERSE('abc') FROM t as t0"), + "postgresql" -> (true, false, "SELECT REVERSE('abc') FROM t as t0"), "h2" -> false, - "mariadb" -> true, - "mysql" -> true, - "duckdb" -> true + "mariadb" -> (true, false, "SELECT REVERSE('abc') FROM t as t0"), + "mysql" -> (true, false, "SELECT REVERSE('abc') FROM t as t0"), + "duckdb" -> (true, false, "SELECT REVERSE('abc') FROM t as t0") ) ) */ From e7c1fde49044d5cb4d3415e14a7274fd2ff36925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sun, 24 Nov 2024 18:34:16 +0100 Subject: [PATCH 078/106] first draft of nulls --- src/main/scala/tyql/ResultTag.scala | 2 + src/main/scala/tyql/expr/Expr.scala | 13 ++++ src/main/scala/tyql/ir/QueryIRTree.scala | 5 ++ src/test/scala/test/integration/null.scala | 70 +++++++++++++++++++ .../scala/test/query/RecursiveTests.scala | 7 +- 5 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 src/test/scala/test/integration/null.scala diff --git a/src/main/scala/tyql/ResultTag.scala b/src/main/scala/tyql/ResultTag.scala index 9138ec7..6171202 100644 --- a/src/main/scala/tyql/ResultTag.scala +++ b/src/main/scala/tyql/ResultTag.scala @@ -6,6 +6,7 @@ import scala.compiletime.{constValue, constValueTuple, summonAll} import scala.deriving.Mirror enum ResultTag[T]: + case NullTag extends ResultTag[scala.Null] case IntTag extends ResultTag[Int] case DoubleTag extends ResultTag[Double] case StringTag extends ResultTag[String] @@ -19,6 +20,7 @@ enum ResultTag[T]: case AnyTag extends ResultTag[Any] // TODO: Add more types, specialize for DB backend object ResultTag: + given ResultTag[scala.Null] = ResultTag.NullTag given ResultTag[Int] = ResultTag.IntTag given ResultTag[String] = ResultTag.StringTag given ResultTag[Boolean] = ResultTag.BoolTag diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index 9ab9190..e0b552b 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -44,6 +44,9 @@ trait Expr[Result, Shape <: ExprShape](using val tag: ResultTag[Result]) extends @targetName("neqScalar") def != (other: Expr[?, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.Ne[Shape, ScalarExpr](this, other) + def isNull[S <: ExprShape]: Expr[Boolean, Shape] = Expr.IsNull(this) + def nullIf[S <: ExprShape](other: Expr[Result, S]): Expr[Result, CalculatedShape[Shape, S]] = Expr.NullIf(this, other) + def cases[DestinationT: ResultTag, SV <: ExprShape](firstCase: (Expr[Result, Shape] | ElseToken, Expr[DestinationT, SV]), restOfCases: (Expr[Result, Shape] | ElseToken, Expr[DestinationT, SV])*): Expr[DestinationT, SV] = type FromT = Result var mainCases: collection.mutable.ArrayBuffer[(Expr[FromT, Shape], Expr[DestinationT, SV])] = collection.mutable.ArrayBuffer.empty @@ -140,6 +143,9 @@ object Expr: def rpad[S2 <: ExprShape](len: Expr[Int, S2], pad: Expr[String, S2]): Expr[String, CalculatedShape[S1, S2]] = Expr.StrRPad(x, len, pad) def findPosition[S2 <: ExprShape](substr: Expr[String, S2]): Expr[Int, CalculatedShape[S1, S2]] = Expr.StrPositionIn(substr, x) + def coalesce[T, S1 <: ExprShape](x: Expr[T, S1], y: Expr[T, S1], xs: Expr[T, S1]*)(using ResultTag[T]): Expr[T, S1] = Coalesce(x, y, xs) + def nullIf[T, S1 <: ExprShape, S2 <: ExprShape](x: Expr[T, S1], y: Expr[T, S2])(using ResultTag[T]): Expr[T, CalculatedShape[S1, S2]] = NullIf(x, y) + def concat[S <: ExprShape](strs: Seq[Expr[String, S]]): Expr[String, S] = assert(strs.nonEmpty, "concat requires at least one argument") StrConcatUniform(strs.head, strs.tail) @@ -295,6 +301,11 @@ object Expr: case class SearchedCase[T, SC <: ExprShape, SV <: ExprShape]($cases: List[(Expr[Boolean, SC], Expr[T, SV])], $else: Option[Expr[T, SV]])(using ResultTag[T]) extends Expr[T, SV] case class SimpleCase[TE, TR, SE <: ExprShape, SR <: ExprShape]($expr: Expr[TE, SE], $cases: List[(Expr[TE, SE], Expr[TR, SR])], $else: Option[Expr[TR, SR]])(using ResultTag[TE], ResultTag[TR]) extends Expr[TR, SR] + case class NullLit[A]()(using ResultTag[A]) extends Expr[A, NonScalarExpr] + case class IsNull[A, S <: ExprShape]($x: Expr[A, S]) extends Expr[Boolean, S] + case class Coalesce[A, S1 <: ExprShape]($x1: Expr[A, S1], $x2: Expr[A, S1], $xs: Seq[Expr[A, S1]])(using ResultTag[A]) extends Expr[A, S1] + case class NullIf[A, S1 <: ExprShape, S2 <: ExprShape]($x: Expr[A, S1], $y: Expr[A, S2])(using ResultTag[A]) extends Expr[A, CalculatedShape[S1, S2]] + /** Literals are type-specific, tailored to the types that the DB supports */ case class IntLit($value: Int) extends Expr[Int, NonScalarExpr] /** Scala values can be lifted into literals by conversions */ @@ -377,5 +388,7 @@ def lit(x: Double): Expr[Double, NonScalarExpr] = Expr.DoubleLit(x) def lit(x: String): Expr[String, NonScalarExpr] = Expr.StringLit(x) def True = Expr.BooleanLit(true) def False = Expr.BooleanLit(false) +def Null = Expr.NullLit[scala.Null]() +def Null[T](using ResultTag[T]) = Expr.NullLit[T]() private case class ElseToken() val Else = new ElseToken() diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index 6c0700a..ddfb3ba 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -347,6 +347,7 @@ object QueryIRTree: case g: Expr.LtDouble[?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l < $r", Precedence.Comparison, g) case a: Expr.And[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l AND $r", Precedence.And, a) case a: Expr.Or[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l OR $r", Precedence.Or, a) + case Expr.Not(inner: Expr.IsNull[?, ?]) => UnaryExprOp(generateExpr(inner.$x, symbols), o => s"$o IS NOT NULL", ast) case n: Expr.Not[?] => UnaryExprOp(generateExpr(n.$x, symbols), o => s"NOT $o", n) case x: Expr.Xor[?] => BinExprOp(generateExpr(x.$x, symbols), generateExpr(x.$y, symbols), ((l, r) => d.xorOperatorSupportedNatively match @@ -395,6 +396,10 @@ object QueryIRTree: Precedence.Concat, a ) + case n: Expr.NullLit[?] => Literal("NULL", n) + case i: Expr.IsNull[?, ?] => UnaryExprOp(generateExpr(i.$x, symbols), o => s"$o IS NULL", i) + case c: Expr.Coalesce[?, ?] => FunctionCallOp("COALESCE", (Seq(c.$x1, c.$x2) ++ c.$xs).map(generateExpr(_, symbols)), c) + case i: Expr.NullIf[?, ?, ?] => FunctionCallOp("NULLIF", Seq(generateExpr(i.$x, symbols), generateExpr(i.$y, symbols)), i) case l: Expr.DoubleLit => Literal(s"${l.$value}", l) case l: Expr.IntLit => Literal(s"${l.$value}", l) case l: Expr.StringLit => Literal(d.quoteStringLiteral(l.$value, insideLikePattern=false), l) diff --git a/src/test/scala/test/integration/null.scala b/src/test/scala/test/integration/null.scala new file mode 100644 index 0000000..31bded1 --- /dev/null +++ b/src/test/scala/test/integration/null.scala @@ -0,0 +1,70 @@ +package test.integration + +import munit.FunSuite +import tyql.* +import test.{withDB, checkExprDialect} +import java.sql.ResultSet +import scalasql.query.Nulls + +private case class R(i: Int) +private val t = Table[R]("t") + +class NullTests extends FunSuite { + def expectB(expected: Boolean)(rs: ResultSet) = assertEquals(rs.getBoolean(1), expected) + def expectI(expected: Int)(rs: ResultSet) = assertEquals(rs.getInt(1), expected) + def expectS(expected: String)(rs: ResultSet) = assertEquals(rs.getString(1), expected) + def expectN(expected: String | scala.Null)(rs: ResultSet) = assertEquals(rs.getString(1), expected) + + test("NULL use compiles at all despite no type tag provided") { + t.map(_ => Null).toQueryIR.toSQLString() + } + + test("isNull") { + checkExprDialect[Boolean](Null.isNull, expectB(true))(withDB.all) + checkExprDialect[Boolean](lit("hello").isNull, expectB(false))(withDB.all) + checkExprDialect[Boolean](lit(101).isNull, expectB(false))(withDB.all) + } + + test("!isNull is simplified") { + checkExprDialect[Boolean](!Null.isNull, expectB(false), s => assert(s.toLowerCase().contains("is not null")))(withDB.all) + checkExprDialect[Boolean](!lit("hello").isNull, expectB(true), s => assert(s.toLowerCase().contains("is not null")))(withDB.all) + checkExprDialect[Boolean](!lit(101).isNull, expectB(true), s => assert(s.toLowerCase().contains("is not null")))(withDB.all) + } + + test("coalesce compiles") { + // XXX the first Null you must type, but the second is inferred + import scala.language.implicitConversions + t.map(_ => Expr.coalesce(1, Null)) + t.map(_ => Expr.coalesce(Null[Int], 1)) + t.map(_ => Expr.coalesce("aaa", Null)) + t.map(_ => Expr.coalesce(Null[String], "aaa")) + t.map(_ => Expr.coalesce(Null[String], "aaa", Null)) + } + + test("coalesce") { + import scala.language.implicitConversions + checkExprDialect[Int](Expr.coalesce(Null, 1), expectI(1))(withDB.all) + checkExprDialect[Int](Expr.coalesce(1, Null), expectI(1))(withDB.all) + checkExprDialect[String](Expr.coalesce("aaa", Null), expectS("aaa"))(withDB.all) + checkExprDialect[String](Expr.coalesce(Null, "aaa"), expectS("aaa"))(withDB.all) + checkExprDialect[String](Expr.coalesce(Null, "aaa", "bbb"), expectS("aaa"))(withDB.all) + checkExprDialect[String](Expr.coalesce("aaa", "bbb", "ccc"), expectS("aaa"))(withDB.all) + } + + test("nullIf") { + import scala.language.implicitConversions + checkExprDialect[String](Expr.nullIf("a", "b"), expectN("a"))(withDB.all) + checkExprDialect[String](Expr.nullIf("a", "a"), expectN(null))(withDB.all) + checkExprDialect[String](Expr.nullIf("a", Null), expectN("a"))(withDB.all) + checkExprDialect[String](Expr.nullIf(Null[String], "b"), expectN(null))(withDB.all) + checkExprDialect[String](Expr.nullIf(Null[String], Null), expectN(null))(withDB.all) + } + + test("nullIf alternative syntax") { + import scala.language.implicitConversions + assertEquals(Expr.nullIf("a", "b"), lit("a").nullIf("b")) + assertEquals(Expr.nullIf(Null[String], "b"), Null[String].nullIf("b")) + assertEquals(Expr.nullIf("a", Null), lit("a").nullIf(Null)) + assertEquals(Expr.nullIf(Null[String], Null), Null[String].nullIf(Null)) + } +} diff --git a/src/test/scala/test/query/RecursiveTests.scala b/src/test/scala/test/query/RecursiveTests.scala index 3d6bea2..df0d6bd 100644 --- a/src/test/scala/test/query/RecursiveTests.scala +++ b/src/test/scala/test/query/RecursiveTests.scala @@ -639,17 +639,16 @@ given TagDBs: TestDatabase[TagDB] with (6, 'Rap', 7), (7, 'Music', 9), (8, 'Movies', 9), - (9, 'Art', -1); + (9, 'Art', NULL); """ class RecursionTreeTest extends SQLStringQueryTest[TagDB, List[String]] { def testDescription: String = "Tag tree example from duckdb docs" def query() = - // For now encode NULL as -1, TODO: implement nulls import Expr.{toRow, toExpr} val tagHierarchy0 = testDB.tables.tag - .filter(t => t.subclassof == -1) + .filter(t => t.subclassof.isNull) .map(t => val initListPath: Expr.ListExpr[String] = List(t.name).toExpr (id = t.id, source = t.name, path = initListPath).toRow @@ -671,7 +670,7 @@ class RecursionTreeTest extends SQLStringQueryTest[TagDB, List[String]] { ((SELECT tag$62.id as id, tag$62.name as source, [tag$62.name] as path FROM tag as tag$62 - WHERE tag$62.subclassof = -1) + WHERE tag$62.subclassof IS NULL) UNION ((SELECT tag$64.id as id, tag$64.name as source, list_prepend(tag$64.name, ref$30.path) as path From 362350ae3e0f3a4a1beb814b3d7f093271b31c54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sun, 24 Nov 2024 18:36:07 +0100 Subject: [PATCH 079/106] a comment --- src/main/scala/tyql/expr/Expr.scala | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index e0b552b..61a2e2c 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -389,6 +389,7 @@ def lit(x: String): Expr[String, NonScalarExpr] = Expr.StringLit(x) def True = Expr.BooleanLit(true) def False = Expr.BooleanLit(false) def Null = Expr.NullLit[scala.Null]() +// TODO a good place for implicitNotFound def Null[T](using ResultTag[T]) = Expr.NullLit[T]() private case class ElseToken() val Else = new ElseToken() From a3c69be2a50643924ab9ff3aeba6833e210543b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Mon, 25 Nov 2024 16:01:19 +0100 Subject: [PATCH 080/106] attempt to use Github CI --- .github/workflows | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows diff --git a/.github/workflows b/.github/workflows new file mode 100644 index 0000000..79b5c77 --- /dev/null +++ b/.github/workflows @@ -0,0 +1,45 @@ +name: Test + +on: + push: + branches: + - better-containerization + pull_request: + branches: + - better-containerization + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and run tests + run: | + # Create test-results directory + mkdir -p test-results + + # Start databases and run tests + docker-compose --profile dbs --profile tests up \ + --build \ + --exit-code-from main \ + --abort-on-container-exit + + - name: Upload test results + if: always() # Run even if tests fail + uses: actions/upload-artifact@v4 + with: + name: test-results + path: test-results/ + retention-days: 14 + + - name: Check test output for failures + run: | + if grep -i "failed" test-results/test-output_*.log; then + exit 1 + fi + From dca23c0eda060fc9ad563623057b973f3c316ad0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Mon, 25 Nov 2024 16:10:48 +0100 Subject: [PATCH 081/106] rename workflows --- .github/{workflows => workflows/test.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{workflows => workflows/test.yml} (100%) diff --git a/.github/workflows b/.github/workflows/test.yml similarity index 100% rename from .github/workflows rename to .github/workflows/test.yml From d6d2840515ac95181d68348bb65ad0141361565e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Mon, 25 Nov 2024 16:13:11 +0100 Subject: [PATCH 082/106] `docker compose` not `docker-compose` --- .github/workflows/test.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 79b5c77..b79b1f8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,13 +18,18 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Install Docker Compose + run: | + sudo apt-get update + sudo apt-get install -y docker-compose-plugin + - name: Build and run tests run: | # Create test-results directory mkdir -p test-results # Start databases and run tests - docker-compose --profile dbs --profile tests up \ + docker compose --profile dbs --profile tests up \ --build \ --exit-code-from main \ --abort-on-container-exit From 0f94b56544ddfcaedaedb8bbe4444e304c267216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Mon, 25 Nov 2024 16:15:45 +0100 Subject: [PATCH 083/106] do not install docker compose --- .github/workflows/test.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b79b1f8..02ec053 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,11 +18,6 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Install Docker Compose - run: | - sudo apt-get update - sudo apt-get install -y docker-compose-plugin - - name: Build and run tests run: | # Create test-results directory From aa8ca344fa9801fdd813a6622159d6823f2b8cf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Mon, 25 Nov 2024 16:21:47 +0100 Subject: [PATCH 084/106] CI: change directory permissions --- .github/workflows/test.yml | 11 ++++++++--- docker-compose.yml | 4 +++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 02ec053..d06d64e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,12 +18,17 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Build and run tests + - name: Create directories and set permissions run: | - # Create test-results directory mkdir -p test-results + mkdir -p target + sudo chown -R 1000:1000 test-results + sudo chown -R 1000:1000 target + sudo chmod -R 777 test-results + sudo chmod -R 777 target - # Start databases and run tests + - name: Build and run tests + run: | docker compose --profile dbs --profile tests up \ --build \ --exit-code-from main \ diff --git a/docker-compose.yml b/docker-compose.yml index 3dce1bc..37f37cb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,9 @@ services: network_mode: host volumes: - ./test-results:/test-results - - .:/app + - ./target:/app/target + - .:/app:ro + user: "1000:1000" depends_on: postgres: condition: service_healthy From db8a1e64d8b69f6701d48cd07affab49b77f258d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Mon, 25 Nov 2024 16:27:22 +0100 Subject: [PATCH 085/106] CI: better test failure parsing --- .github/workflows/test.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d06d64e..bdf64f2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,7 +44,11 @@ jobs: - name: Check test output for failures run: | - if grep -i "failed" test-results/test-output_*.log; then + if grep -q "Passed: Total .*, Failed 0, Errors 0," test-results/test-output_*.log; then + echo "All tests passed!" + exit 0 + else + echo "Tests failed!" exit 1 fi From 5f9873cd3ab942cfbc9f9a1611462cd79e57ad3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 26 Nov 2024 18:49:09 +0100 Subject: [PATCH 086/106] some mathematical operations --- src/main/scala/tyql/expr/Expr.scala | 49 ++++++++++-- src/main/scala/tyql/ir/QueryIRTree.scala | 17 +++++ .../scala/test/integration/mathematical.scala | 76 +++++++++++++++++++ 3 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 src/test/scala/test/integration/mathematical.scala diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index 61a2e2c..bb4d01a 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -82,16 +82,12 @@ object Expr: @targetName("addIntNonScalar") def +(y: Expr[Int, NonScalarExpr]): Expr[Int, CalculatedShape[S1, NonScalarExpr]] = Plus(x, y) def +(y: Int): Expr[Int, S1] = Plus[S1, NonScalarExpr, Int](x, IntLit(y)) - @targetName("subtractIntScalar") - def -(y: Expr[Int, ScalarExpr]): Expr[Int, CalculatedShape[S1, ScalarExpr]] = Minus(x, y) - @targetName("subtractIntNonScalar") - def -(y: Expr[Int, NonScalarExpr]): Expr[Int, CalculatedShape[S1, NonScalarExpr]] = Minus(x, y) - def -(y: Int): Expr[Int, S1] = Minus[S1, NonScalarExpr, Int](x, IntLit(y)) @targetName("multiplyIntScalar") def *(y: Expr[Int, ScalarExpr]): Expr[Int, CalculatedShape[S1, ScalarExpr]] = Times(x, y) @targetName("multiplyIntNonScalar") def *(y: Expr[Int, NonScalarExpr]): Expr[Int, CalculatedShape[S1, NonScalarExpr]] = Times(x, y) def *(y: Int): Expr[Int, S1] = Times(x, IntLit(y)) + def %[S2 <: ExprShape](y: Expr[Int, S2]): Expr[Int, CalculatedShape[S1, S2]] = Modulo(x, y) // TODO: write for numerical extension [S1 <: ExprShape](x: Expr[Double, S1]) @@ -107,6 +103,29 @@ object Expr: def *[S2 <: ExprShape](y: Expr[Double, S2]): Expr[Double, CalculatedShape[S1, S2]] = Times(x, y) def *(y: Double): Expr[Double, S1] = Times[S1, NonScalarExpr, Double](x, DoubleLit(y)) + extension [T: Numeric, S1 <: ExprShape](x: Expr[T, S1])(using ResultTag[T]) + def abs: Expr[T, S1] = Abs(x) + def sqrt: Expr[Double, S1] = Sqrt(x) + def round: Expr[Int, S1] = Round(x) + def round[S2 <: ExprShape](precision: Expr[Int, S2]): Expr[Double, CalculatedShape[S1, S2]] = RoundWithPrecision(x, precision) + def ceil: Expr[Int, S1] = Ceil(x) + def floor: Expr[Int, S1] = Floor(x) + def power[S2 <: ExprShape](y: Expr[Double, S2]): Expr[Double, CalculatedShape[S1, S2]] = Power(x, y) + def sign: Expr[Int, S1] = Sign(x) + def ln: Expr[Double, S1] = LogNatural(x) + def log(base: Expr[T, S1]): Expr[Double, CalculatedShape[S1, S1]] = Log(base, x) + def log10: Expr[Double, S1] = Log(IntLit(10), x).asInstanceOf[Expr[Double, S1]] // TODO cast? + def log2: Expr[Double, S1] = Log(IntLit(2), x).asInstanceOf[Expr[Double, S1]] // TODO cast? + def -[S2 <: ExprShape](y: Expr[T, S2]): Expr[T, CalculatedShape[S1, S2]] = Minus(x, y) + + def exp[T: Numeric, S <: ExprShape](x: Expr[T, S])(using ResultTag[T]): Expr[Double, S] = Exp(x) + def sin[T: Numeric, S <: ExprShape](x: Expr[T, S])(using ResultTag[T]): Expr[Double, S] = Sin(x) + def cos[T: Numeric, S <: ExprShape](x: Expr[T, S])(using ResultTag[T]): Expr[Double, S] = Cos(x) + def tan[T: Numeric, S <: ExprShape](x: Expr[T, S])(using ResultTag[T]): Expr[Double, S] = Tan(x) + def asin[T: Numeric, S <: ExprShape](x: Expr[T, S])(using ResultTag[T]): Expr[Double, S] = Asin(x) + def acos[T: Numeric, S <: ExprShape](x: Expr[T, S])(using ResultTag[T]): Expr[Double, S] = Acos(x) + def atan[T: Numeric, S <: ExprShape](x: Expr[T, S])(using ResultTag[T]): Expr[Double, S] = Atan(x) + extension [S1 <: ExprShape](x: Expr[Boolean, S1]) def &&[S2 <: ExprShape] (y: Expr[Boolean, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = And(x, y) def ||[S2 <: ExprShape] (y: Expr[Boolean, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = Or(x, y) @@ -242,6 +261,26 @@ object Expr: case class StrRPad[S1 <: ExprShape, S2 <: ExprShape]($s: Expr[String, S1], $len: Expr[Int, S2], $pad: Expr[String, S2]) extends Expr[String, CalculatedShape[S1, S2]] case class StrPositionIn[S1 <: ExprShape, S2 <: ExprShape]($substr: Expr[String, S2], $string: Expr[String, S1]) extends Expr[Int, CalculatedShape[S1, S2]] + case class Modulo[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Int, S1], $y: Expr[Int, S2]) extends Expr[Int, CalculatedShape[S1, S2]] + // TODO actually, it's unclear for now what types should be here, the input to ROUND() in most DBs can be any numeric and the ouput is usually of the same type as the input + case class Round[S1 <: ExprShape, T: Numeric]($x: Expr[T, S1])(using ResultTag[T]) extends Expr[Int, S1] + case class RoundWithPrecision[S1 <: ExprShape, S2 <: ExprShape, T: Numeric]($x: Expr[T, S1], $precision: Expr[Int, S2])(using ResultTag[T]) extends Expr[Double, CalculatedShape[S1, S2]] + case class Ceil[S1 <: ExprShape, T: Numeric]($x: Expr[T, S1])(using ResultTag[T]) extends Expr[Int, S1] + case class Floor[S1 <: ExprShape, T: Numeric]($x: Expr[T, S1])(using ResultTag[T]) extends Expr[Int, S1] + case class Power[S1 <: ExprShape, S2 <: ExprShape, T1: Numeric, T2: Numeric]($x: Expr[T1, S1], $y: Expr[T2, S2])(using ResultTag[T1], ResultTag[T2]) extends Expr[Double, CalculatedShape[S1, S2]] + case class Sqrt[S1 <: ExprShape, T: Numeric]($x: Expr[T, S1])(using ResultTag[T]) extends Expr[Double, S1] + case class Abs[S1 <: ExprShape, T: Numeric]($x: Expr[T, S1])(using ResultTag[T]) extends Expr[T, S1] + case class Sign[S1 <: ExprShape, T: Numeric]($x: Expr[T, S1])(using ResultTag[T]) extends Expr[Int, S1] + case class LogNatural[S1 <: ExprShape, T: Numeric]($x: Expr[T, S1])(using ResultTag[T]) extends Expr[Double, S1] + case class Log[S1 <: ExprShape, S2 <: ExprShape, T1: Numeric, T2: Numeric]($base: Expr[T1, S1], $x: Expr[T2, S2])(using ResultTag[T1], ResultTag[T2]) extends Expr[Double, CalculatedShape[S1, S2]] + case class Exp[S1 <: ExprShape, T: Numeric]($x: Expr[T, S1])(using ResultTag[T]) extends Expr[Double, S1] + case class Sin[S1 <: ExprShape, T: Numeric]($x: Expr[T, S1])(using ResultTag[T]) extends Expr[Double, S1] + case class Cos[S1 <: ExprShape, T: Numeric]($x: Expr[T, S1])(using ResultTag[T]) extends Expr[Double, S1] + case class Tan[S1 <: ExprShape, T: Numeric]($x: Expr[T, S1])(using ResultTag[T]) extends Expr[Double, S1] + case class Asin[S1 <: ExprShape, T: Numeric]($x: Expr[T, S1])(using ResultTag[T]) extends Expr[Double, S1] + case class Acos[S1 <: ExprShape, T: Numeric]($x: Expr[T, S1])(using ResultTag[T]) extends Expr[Double, S1] + case class Atan[S1 <: ExprShape, T: Numeric]($x: Expr[T, S1])(using ResultTag[T]) extends Expr[Double, S1] + case class RandomUUID() extends Expr[String, NonScalarExpr] // XXX NonScalarExpr? case class RandomFloat() extends Expr[Double, NonScalarExpr] // XXX NonScalarExpr? case class RandomInt[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Int, S1], $y: Expr[Int, S2]) extends Expr[Int, CalculatedShape[S1, S2]] diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index ddfb3ba..d91d875 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -379,6 +379,23 @@ object QueryIRTree: case a: Expr.Times[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l * $r", Precedence.Multiplicative, a) case a: Expr.Eq[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l = $r", Precedence.Comparison, a) case a: Expr.Ne[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l <> $r", Precedence.Comparison, a) + case a: Expr.Modulo[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l % $r", Precedence.Multiplicative, a) + case r: Expr.Round[?, ?] => FunctionCallOp("ROUND", Seq(generateExpr(r.$x, symbols)), r) + case r: Expr.RoundWithPrecision[?, ?, ?] => FunctionCallOp("ROUND", Seq(generateExpr(r.$x, symbols), generateExpr(r.$precision, symbols)), r) + case c: Expr.Ceil[?, ?] => FunctionCallOp("CEIL", Seq(generateExpr(c.$x, symbols)), c) + case f: Expr.Floor[?, ?] => FunctionCallOp("FLOOR", Seq(generateExpr(f.$x, symbols)), f) + case p: Expr.Power[?, ?, ?, ?] => FunctionCallOp("POWER", Seq(generateExpr(p.$x, symbols), generateExpr(p.$y, symbols)), p) + case s: Expr.Sqrt[?, ?] => FunctionCallOp("SQRT", Seq(generateExpr(s.$x, symbols)), s) + case s: Expr.Sign[?, ?] => FunctionCallOp("SIGN", Seq(generateExpr(s.$x, symbols)), s) + case l: Expr.LogNatural[?, ?] => FunctionCallOp("LN", Seq(generateExpr(l.$x, symbols)), l) + case l: Expr.Log[?, ?, ?, ?] => FunctionCallOp("LOG", Seq(generateExpr(l.$base, symbols), generateExpr(l.$x, symbols)), l) + case e: Expr.Exp[?, ?] => FunctionCallOp("EXP", Seq(generateExpr(e.$x, symbols)), e) + case s: Expr.Sin[?, ?] => FunctionCallOp("SIN", Seq(generateExpr(s.$x, symbols)), s) + case s: Expr.Cos[?, ?] => FunctionCallOp("COS", Seq(generateExpr(s.$x, symbols)), s) + case s: Expr.Tan[?, ?] => FunctionCallOp("TAN", Seq(generateExpr(s.$x, symbols)), s) + case s: Expr.Asin[?, ?] => FunctionCallOp("ASIN", Seq(generateExpr(s.$x, symbols)), s) + case s: Expr.Acos[?, ?] => FunctionCallOp("ACOS", Seq(generateExpr(s.$x, symbols)), s) + case s: Expr.Atan[?, ?] => FunctionCallOp("ATAN", Seq(generateExpr(s.$x, symbols)), s) case a: Expr.Concat[?, ?, ?, ?] => val lhsIR = generateExpr(a.$x, symbols) match case p: ProjectClause => p diff --git a/src/test/scala/test/integration/mathematical.scala b/src/test/scala/test/integration/mathematical.scala new file mode 100644 index 0000000..b487386 --- /dev/null +++ b/src/test/scala/test/integration/mathematical.scala @@ -0,0 +1,76 @@ +package test.integration + +import munit.FunSuite +import java.sql.ResultSet +import tyql.* +import test.{checkExpr, withDBNoImplicits} +import scala.language.implicitConversions + +class MathematicalOperationsTest extends FunSuite { + def expectI(expected: Int)(rs: ResultSet) = assertEquals(rs.getInt(1), expected) + def expectD(expected: Double)(rs: ResultSet) = + val d = rs.getDouble(1) + assert(Math.abs(d - expected) < 0.0001, s"Expected $expected, got $d") + + test("modulo") { + checkExpr[Int](lit(10) % 3, expectI(1))(withDBNoImplicits.all) + checkExpr[Int](lit(-5) % 3, expectI(-2))(withDBNoImplicits.all) + checkExpr[Int](lit(-6) % 3, expectI(0))(withDBNoImplicits.all) + checkExpr[Int](lit(-4) % 3, expectI(-1))(withDBNoImplicits.all) + } + + test("rounding") { + checkExpr[Int](lit(0.5).round, expectI(1))(withDBNoImplicits.all) + checkExpr[Int](lit(0.49999).round, expectI(0))(withDBNoImplicits.all) + checkExpr[Int](lit(-0.49999).round, expectI(0))(withDBNoImplicits.all) + checkExpr[Int](lit(-0.5).round, expectI(-1))(withDBNoImplicits.all) + + checkExpr[Double](lit(0.1264).round(0), expectD(0))(withDBNoImplicits.all) + checkExpr[Double](lit(0.1264).round(1), expectD(0.1))(withDBNoImplicits.all) + checkExpr[Double](lit(0.1264).round(2), expectD(0.13))(withDBNoImplicits.all) + checkExpr[Double](lit(0.1264).round(3), expectD(0.126))(withDBNoImplicits.all) + } + + test("ceil, floor") { + checkExpr[Int](lit(0.5).ceil, expectI(1))(withDBNoImplicits.all) + checkExpr[Int](lit(0.5).floor, expectI(0))(withDBNoImplicits.all) + checkExpr[Int](lit(-0.5).ceil, expectI(0))(withDBNoImplicits.all) + checkExpr[Int](lit(-0.5).floor, expectI(-1))(withDBNoImplicits.all) + checkExpr[Int](lit(.0).floor, expectI(0))(withDBNoImplicits.all) + checkExpr[Int](lit(.0).ceil, expectI(0))(withDBNoImplicits.all) + } + + test("power, sqrt") { + checkExpr[Double](lit(10).power(3.0), expectD(1000.0))(withDBNoImplicits.all) + checkExpr[Double](lit(100).power(.5), expectD(10.0))(withDBNoImplicits.all) + checkExpr[Double](lit(100).power(.0), expectD(1.0))(withDBNoImplicits.all) + + checkExpr[Double](lit(100).sqrt, expectD(10.0))(withDBNoImplicits.all) + checkExpr[Double](lit(1).sqrt, expectD(1.0))(withDBNoImplicits.all) + checkExpr[Double](lit(0).sqrt, expectD(0.0))(withDBNoImplicits.all) + } + + test("sign") { + checkExpr[Int](lit(10).sign, expectI(1))(withDBNoImplicits.all) + checkExpr[Int](lit(10.4).sign, expectI(1))(withDBNoImplicits.all) + checkExpr[Int](lit(-10.5).sign, expectI(-1))(withDBNoImplicits.all) + checkExpr[Int](lit(-10).sign, expectI(-1))(withDBNoImplicits.all) + checkExpr[Int](lit(0).sign, expectI(0))(withDBNoImplicits.all) + checkExpr[Int](lit(0.0).sign, expectI(0))(withDBNoImplicits.all) + } + + test("logarithms") { + checkExpr[Double](lit(10).ln, expectD(2.302585092994046))(withDBNoImplicits.all) + checkExpr[Double](lit(10).log10, expectD(1.0))(withDBNoImplicits.all) + checkExpr[Double](lit(1024).log2, expectD(10))(withDBNoImplicits.all) + checkExpr[Double](tyql.Expr.exp(13).ln, expectD(13))(withDBNoImplicits.all) + } + + test("trigonometric functions") { + checkExpr[Double](Expr.sin(100).power(2.0) + Expr.cos(100) * Expr.cos(100), expectD(1.0))(withDBNoImplicits.all) + checkExpr[Double](Expr.sin(Expr.asin(0.76)), expectD(0.76))(withDBNoImplicits.all) + checkExpr[Double](Expr.cos(Expr.acos(0.76)), expectD(0.76))(withDBNoImplicits.all) + checkExpr[Double](Expr.tan(Expr.atan(0.76)), expectD(0.76))(withDBNoImplicits.all) + checkExpr[Double](Expr.cos(17).power(2.0) - Expr.sin(17).power(2.0), expectD(Math.cos(17*2)))(withDBNoImplicits.all) + } +} From 58395110e93a1e823aacf283fcf9f4643c6cd944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 26 Nov 2024 19:44:21 +0100 Subject: [PATCH 087/106] first draft of null-safe equality --- src/main/scala/tyql/dialects/dialects.scala | 3 ++ src/main/scala/tyql/expr/Expr.scala | 10 ++++++ src/main/scala/tyql/ir/QueryIRTree.scala | 17 +++++++++ .../test/integration/null-safe equality.scala | 36 +++++++++++++++++++ 4 files changed, 66 insertions(+) create mode 100644 src/test/scala/test/integration/null-safe equality.scala diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index 22d1892..12052eb 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -34,6 +34,8 @@ trait Dialect: def stringPositionFindingVia: String = "LOCATE" + val nullSafeEqualityViaSpecialOperator: Boolean = false + object Dialect: val literal_percent = '\uE000' val literal_underscore = '\uE001' @@ -85,6 +87,7 @@ object Dialect: val a = ("a", Precedence.Concat) val b = ("b", Precedence.Concat) SqlSnippet(Precedence.Unary, snippet"(with randomIntParameters as (select $a as a, $b as b) select floor(rand() * (b - a + 1) + a) from randomIntParameters)") + override val nullSafeEqualityViaSpecialOperator: Boolean = true given RandomUUID = new RandomUUID {} given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index bb4d01a..c25d205 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -34,6 +34,10 @@ trait Expr[Result, Shape <: ExprShape](using val tag: ResultTag[Result]) extends def ==(other: Expr[?, NonScalarExpr]): Expr[Boolean, Shape] = Expr.Eq[Shape, NonScalarExpr](this, other) @targetName("eqScalar") def ==(other: Expr[?, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.Eq[Shape, ScalarExpr](this, other) + @targetName("nullSafeEqNonScalar") + def ===(other: Expr[Result, NonScalarExpr]): Expr[Boolean, Shape] = Expr.NullSafeEq[Shape, NonScalarExpr, Result](this, other) + @targetName("nullSafeEqScalar") + def ===(other: Expr[Result, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.NullSafeEq[Shape, ScalarExpr, Result](this, other) // def == [S <: ScalarExpr](other: Expr[?, S]): Expr[Boolean, CalculatedShape[Shape, S]] = Expr.Eq(this, other) def ==(other: String): Expr[Boolean, Shape] = Expr.Eq(this, Expr.StringLit(other)) def ==(other: Int): Expr[Boolean, Shape] = Expr.Eq(this, Expr.IntLit(other)) @@ -43,6 +47,10 @@ trait Expr[Result, Shape <: ExprShape](using val tag: ResultTag[Result]) extends def != (other: Expr[?, NonScalarExpr]): Expr[Boolean, Shape] = Expr.Ne[Shape, NonScalarExpr](this, other) @targetName("neqScalar") def != (other: Expr[?, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.Ne[Shape, ScalarExpr](this, other) + @targetName("nullSafeNeqNonScalar") + def !== (other: Expr[Result, NonScalarExpr]): Expr[Boolean, Shape] = Expr.NullSafeNe[Shape, NonScalarExpr, Result](this, other) + @targetName("nullSafeNeqScalar") + def !== (other: Expr[Result, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.NullSafeNe[Shape, ScalarExpr, Result](this, other) def isNull[S <: ExprShape]: Expr[Boolean, Shape] = Expr.IsNull(this) def nullIf[S <: ExprShape](other: Expr[Result, S]): Expr[Result, CalculatedShape[Shape, S]] = Expr.NullIf(this, other) @@ -312,6 +320,8 @@ object Expr: // Also weakly typed in the arguments since these two classes model universal equality */ case class Eq[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[?, S1], $y: Expr[?, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] case class Ne[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[?, S1], $y: Expr[?, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] + case class NullSafeEq[S1 <: ExprShape, S2 <: ExprShape, T]($x: Expr[T, S1], $y: Expr[T, S2])(using ResultTag[T]) extends Expr[Boolean, CalculatedShape[S1, S2]] + case class NullSafeNe[S1 <: ExprShape, S2 <: ExprShape, T]($x: Expr[T, S1], $y: Expr[T, S2])(using ResultTag[T]) extends Expr[Boolean, CalculatedShape[S1, S2]] // Expressions resulting from queries // Cannot use Contains with an aggregation diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index d91d875..a451012 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -379,6 +379,23 @@ object QueryIRTree: case a: Expr.Times[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l * $r", Precedence.Multiplicative, a) case a: Expr.Eq[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l = $r", Precedence.Comparison, a) case a: Expr.Ne[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l <> $r", Precedence.Comparison, a) + case e: Expr.NullSafeEq[?, ?, ?] => + val a = ("a", Precedence.Comparison) + val b = ("b", Precedence.Comparison) + RawSQLInsertOp(SqlSnippet(Precedence.Comparison, + if d.nullSafeEqualityViaSpecialOperator then + snippet"$a <=> $b" + else + snippet"$a IS NOT DISTINCT FROM $b" + ), + Map(a._1 -> generateExpr(e.$x, symbols), b._1 -> generateExpr(e.$y, symbols)), Precedence.Comparison, e) + case e: Expr.NullSafeNe[?, ?, ?] => + val a = ("a", Precedence.Comparison) + val b = ("b", Precedence.Comparison) + if d.nullSafeEqualityViaSpecialOperator then + RawSQLInsertOp(SqlSnippet(Precedence.Unary, snippet"NOT($a <=> $b)"), Map(a._1 -> generateExpr(e.$x, symbols), b._1 -> generateExpr(e.$y, symbols)), Precedence.Unary, e) + else + RawSQLInsertOp(SqlSnippet(Precedence.Comparison, snippet"$a IS DISTINCT FROM $b"), Map(a._1 -> generateExpr(e.$x, symbols), b._1 -> generateExpr(e.$y, symbols)), Precedence.Comparison, e) case a: Expr.Modulo[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l % $r", Precedence.Multiplicative, a) case r: Expr.Round[?, ?] => FunctionCallOp("ROUND", Seq(generateExpr(r.$x, symbols)), r) case r: Expr.RoundWithPrecision[?, ?, ?] => FunctionCallOp("ROUND", Seq(generateExpr(r.$x, symbols), generateExpr(r.$precision, symbols)), r) diff --git a/src/test/scala/test/integration/null-safe equality.scala b/src/test/scala/test/integration/null-safe equality.scala new file mode 100644 index 0000000..b7fd941 --- /dev/null +++ b/src/test/scala/test/integration/null-safe equality.scala @@ -0,0 +1,36 @@ +package test.integration + +import munit.FunSuite +import java.sql.ResultSet +import tyql.* +import test.{checkExpr, checkExprDialect, withDBNoImplicits, withDB} +import scala.language.implicitConversions + +class NullSafeEqualityTest extends FunSuite { + def expectN(expected: String | scala.Null)(rs: ResultSet) = assertEquals(rs.getString(1), expected) + def expectB(expected: Boolean)(rs: ResultSet) = assertEquals(rs.getBoolean(1), expected) + + test("non-null-safe equality") { + checkExpr[Boolean](lit("a") == "a", expectB(true))(withDBNoImplicits.all) + checkExpr[Boolean](lit("a") == "b", expectB(false))(withDBNoImplicits.all) + // checkExpr[Boolean](lit("a") == 10, expectB(false), println)(withDBNoImplicits.all) + /* + TODO: + Postgres and DuckDB do not allow you to compare things that are not of the same type affinity at all. + Currently we implement universal equality for == (which will break at runtime for Postgres and DuckDB), + for the null-safe equality we only allow the same types (which is too restrictive). + What to do about this? + */ + checkExpr[Boolean](lit("a") == tyql.Null, expectN(null))(withDBNoImplicits.all) + checkExpr[Boolean](tyql.Null == lit("a"), expectN(null))(withDBNoImplicits.all) + checkExpr[Boolean](tyql.Null == tyql.Null, expectN(null))(withDBNoImplicits.all) + } + + test("null-safe equality") { + checkExprDialect[Boolean](lit("a") === "a", expectB(true))(withDB.all) + checkExprDialect[Boolean](lit("a") === "b", expectB(false))(withDB.all) + checkExprDialect[Boolean](lit("a") === tyql.Null[String], expectB(false))(withDB.all) + checkExprDialect[Boolean](tyql.Null[String] === lit("a"), expectB(false))(withDB.all) + checkExprDialect[Boolean](tyql.Null === tyql.Null, expectB(true))(withDB.all) + } +} From 611cf81e4d202077ac5b097b9ae11ca4eb84ddca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 26 Nov 2024 20:16:08 +0100 Subject: [PATCH 088/106] === equality can be weakly-typed if the dialect allows it --- .../tyql/dialects/dialect features.scala | 2 + src/main/scala/tyql/dialects/dialects.scala | 4 ++ src/main/scala/tyql/expr/Expr.scala | 47 +++++++++++++------ src/main/scala/tyql/ir/QueryIRTree.scala | 4 +- .../test/integration/null-safe equality.scala | 36 ++++++++++---- 5 files changed, 66 insertions(+), 27 deletions(-) diff --git a/src/main/scala/tyql/dialects/dialect features.scala b/src/main/scala/tyql/dialects/dialect features.scala index a4342be..a13b82b 100644 --- a/src/main/scala/tyql/dialects/dialect features.scala +++ b/src/main/scala/tyql/dialects/dialect features.scala @@ -7,3 +7,5 @@ object DialectFeature: trait RandomIntegerInInclusiveRange extends DialectFeature trait ReversibleStrings extends DialectFeature + + trait WeaklyTypedEquality extends DialectFeature diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index 12052eb..8b66839 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -92,6 +92,7 @@ object Dialect: given RandomUUID = new RandomUUID {} given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} given ReversibleStrings = new ReversibleStrings {} + given WeaklyTypedEquality = new WeaklyTypedEquality {} object mariadb: // XXX MariaDB extends MySQL @@ -102,6 +103,7 @@ object Dialect: given RandomUUID = mysql.given_RandomUUID given RandomIntegerInInclusiveRange = mysql.given_RandomIntegerInInclusiveRange given ReversibleStrings = mysql.given_ReversibleStrings + given WeaklyTypedEquality = mysql.given_WeaklyTypedEquality object sqlite: given Dialect = new Dialect @@ -123,6 +125,7 @@ object Dialect: given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} given ReversibleStrings = new ReversibleStrings {} + given WeaklyTypedEquality = new WeaklyTypedEquality {} object h2: given Dialect = new Dialect @@ -142,6 +145,7 @@ object Dialect: given RandomUUID = new RandomUUID {} given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} + given WeaklyTypedEquality = new WeaklyTypedEquality {} object duckdb: given Dialect = new Dialect diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index c25d205..bf1de9b 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -30,27 +30,44 @@ trait Expr[Result, Shape <: ExprShape](using val tag: ResultTag[Result]) extends def selectDynamic(fieldName: String) = Expr.Select(this, fieldName) /** Member methods to implement universal equality on Expr level. */ - @targetName("eqNonScalar") - def ==(other: Expr[?, NonScalarExpr]): Expr[Boolean, Shape] = Expr.Eq[Shape, NonScalarExpr](this, other) - @targetName("eqScalar") - def ==(other: Expr[?, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.Eq[Shape, ScalarExpr](this, other) + // @targetName("eqNonScalar") + // def ==(other: Expr[Result, NonScalarExpr]): Expr[Boolean, Shape] = Expr.Eq[Shape, NonScalarExpr](this, other) + // @targetName("eqScalar") + // def ==(other: Expr[Result, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.Eq[Shape, ScalarExpr](this, other) + @targetName("eqNonScalarUniversal") + def ==[T](other: Expr[T, NonScalarExpr])(using ResultTag[T]/*, tyql.DialectFeature.WeaklyTypedEquality*/): Expr[Boolean, Shape] = Expr.Eq[Shape, NonScalarExpr](this, other) + @targetName("eqScalarUniversal") + def ==[T](other: Expr[T, ScalarExpr])(using ResultTag[T]/*, tyql.DialectFeature.WeaklyTypedEquality*/): Expr[Boolean, ScalarExpr] = Expr.Eq[Shape, ScalarExpr](this, other) @targetName("nullSafeEqNonScalar") - def ===(other: Expr[Result, NonScalarExpr]): Expr[Boolean, Shape] = Expr.NullSafeEq[Shape, NonScalarExpr, Result](this, other) + def ===(other: Expr[Result, NonScalarExpr]): Expr[Boolean, Shape] = Expr.NullSafeEq[Shape, NonScalarExpr](this, other) @targetName("nullSafeEqScalar") - def ===(other: Expr[Result, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.NullSafeEq[Shape, ScalarExpr, Result](this, other) + def ===(other: Expr[Result, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.NullSafeEq[Shape, ScalarExpr](this, other) + @targetName("nullSafeEqNonScalarUniversal") + def ===[T](other: Expr[T, NonScalarExpr])(using ResultTag[T], tyql.DialectFeature.WeaklyTypedEquality): Expr[Boolean, Shape] = Expr.NullSafeEq[Shape, NonScalarExpr](this, other) + @targetName("nullSafeEqScalarUniversal") + def ===[T](other: Expr[T, ScalarExpr])(using ResultTag[T], tyql.DialectFeature.WeaklyTypedEquality): Expr[Boolean, ScalarExpr] = Expr.NullSafeEq[Shape, ScalarExpr](this, other) // def == [S <: ScalarExpr](other: Expr[?, S]): Expr[Boolean, CalculatedShape[Shape, S]] = Expr.Eq(this, other) def ==(other: String): Expr[Boolean, Shape] = Expr.Eq(this, Expr.StringLit(other)) def ==(other: Int): Expr[Boolean, Shape] = Expr.Eq(this, Expr.IntLit(other)) def ==(other: Boolean): Expr[Boolean, Shape] = Expr.Eq(this, Expr.BooleanLit(other)) - @targetName("neqNonScalar") - def != (other: Expr[?, NonScalarExpr]): Expr[Boolean, Shape] = Expr.Ne[Shape, NonScalarExpr](this, other) - @targetName("neqScalar") - def != (other: Expr[?, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.Ne[Shape, ScalarExpr](this, other) + // @targetName("neqNonScalar") + // def != (other: Expr[Result, NonScalarExpr]): Expr[Boolean, Shape] = Expr.Ne[Shape, NonScalarExpr](this, other) + // @targetName("neqScalar") + // def != (other: Expr[Result, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.Ne[Shape, ScalarExpr](this, other) + @targetName("neqNonScalarUniversal") + def != [T](other: Expr[T, NonScalarExpr])(using ResultTag[T]/*, tyql.DialectFeature.WeaklyTypedEquality*/): Expr[Boolean, Shape] = Expr.Ne[Shape, NonScalarExpr](this, other) + @targetName("neqScalarUniversal") + def != [T](other: Expr[T, ScalarExpr])(using ResultTag[T]/*, tyql.DialectFeature.WeaklyTypedEquality*/): Expr[Boolean, ScalarExpr] = Expr.Ne[Shape, ScalarExpr](this, other) @targetName("nullSafeNeqNonScalar") - def !== (other: Expr[Result, NonScalarExpr]): Expr[Boolean, Shape] = Expr.NullSafeNe[Shape, NonScalarExpr, Result](this, other) + def !== (other: Expr[Result, NonScalarExpr]): Expr[Boolean, Shape] = Expr.NullSafeNe[Shape, NonScalarExpr](this, other) @targetName("nullSafeNeqScalar") - def !== (other: Expr[Result, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.NullSafeNe[Shape, ScalarExpr, Result](this, other) + def !== (other: Expr[Result, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.NullSafeNe[Shape, ScalarExpr](this, other) + @targetName("nullSafeNeqNonScalarUniveral") + def !== [T](other: Expr[T, NonScalarExpr])(using ResultTag[T], tyql.DialectFeature.WeaklyTypedEquality): Expr[Boolean, Shape] = Expr.NullSafeNe[Shape, NonScalarExpr](this, other) + @targetName("nullSafeNeqScalarUniveral") + def !== [T](other: Expr[T, ScalarExpr])(using ResultTag[T], tyql.DialectFeature.WeaklyTypedEquality): Expr[Boolean, ScalarExpr] = Expr.NullSafeNe[Shape, ScalarExpr](this, other) + def isNull[S <: ExprShape]: Expr[Boolean, Shape] = Expr.IsNull(this) def nullIf[S <: ExprShape](other: Expr[Result, S]): Expr[Result, CalculatedShape[Shape, S]] = Expr.NullIf(this, other) @@ -305,7 +322,7 @@ object Expr: // So far Select is weakly typed, so `selectDynamic` is easy to implement. // TODO: Make it strongly typed like the other cases - case class Select[A: ResultTag]($x: Expr[A, ?], $name: String) extends Expr[A, NonScalarExpr] + case class Select[A: ResultTag]($x: Expr[A, ?], $name: String) extends Expr[A, NonScalarExpr] // TODO is this type correct? X is of type A and it's child under `name` is also of type A? // case class Single[S <: String, A]($x: Expr[A])(using ResultTag[NamedTuple[S *: EmptyTuple, A *: EmptyTuple]]) extends Expr[NamedTuple[S *: EmptyTuple, A *: EmptyTuple]] @@ -320,8 +337,8 @@ object Expr: // Also weakly typed in the arguments since these two classes model universal equality */ case class Eq[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[?, S1], $y: Expr[?, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] case class Ne[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[?, S1], $y: Expr[?, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] - case class NullSafeEq[S1 <: ExprShape, S2 <: ExprShape, T]($x: Expr[T, S1], $y: Expr[T, S2])(using ResultTag[T]) extends Expr[Boolean, CalculatedShape[S1, S2]] - case class NullSafeNe[S1 <: ExprShape, S2 <: ExprShape, T]($x: Expr[T, S1], $y: Expr[T, S2])(using ResultTag[T]) extends Expr[Boolean, CalculatedShape[S1, S2]] + case class NullSafeEq[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[?, S1], $y: Expr[?, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] + case class NullSafeNe[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[?, S1], $y: Expr[?, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] // Expressions resulting from queries // Cannot use Contains with an aggregation diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index a451012..fdf43d5 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -379,7 +379,7 @@ object QueryIRTree: case a: Expr.Times[?, ?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l * $r", Precedence.Multiplicative, a) case a: Expr.Eq[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l = $r", Precedence.Comparison, a) case a: Expr.Ne[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l <> $r", Precedence.Comparison, a) - case e: Expr.NullSafeEq[?, ?, ?] => + case e: Expr.NullSafeEq[?, ?] => val a = ("a", Precedence.Comparison) val b = ("b", Precedence.Comparison) RawSQLInsertOp(SqlSnippet(Precedence.Comparison, @@ -389,7 +389,7 @@ object QueryIRTree: snippet"$a IS NOT DISTINCT FROM $b" ), Map(a._1 -> generateExpr(e.$x, symbols), b._1 -> generateExpr(e.$y, symbols)), Precedence.Comparison, e) - case e: Expr.NullSafeNe[?, ?, ?] => + case e: Expr.NullSafeNe[?, ?] => val a = ("a", Precedence.Comparison) val b = ("b", Precedence.Comparison) if d.nullSafeEqualityViaSpecialOperator then diff --git a/src/test/scala/test/integration/null-safe equality.scala b/src/test/scala/test/integration/null-safe equality.scala index b7fd941..d2d7028 100644 --- a/src/test/scala/test/integration/null-safe equality.scala +++ b/src/test/scala/test/integration/null-safe equality.scala @@ -13,16 +13,8 @@ class NullSafeEqualityTest extends FunSuite { test("non-null-safe equality") { checkExpr[Boolean](lit("a") == "a", expectB(true))(withDBNoImplicits.all) checkExpr[Boolean](lit("a") == "b", expectB(false))(withDBNoImplicits.all) - // checkExpr[Boolean](lit("a") == 10, expectB(false), println)(withDBNoImplicits.all) - /* - TODO: - Postgres and DuckDB do not allow you to compare things that are not of the same type affinity at all. - Currently we implement universal equality for == (which will break at runtime for Postgres and DuckDB), - for the null-safe equality we only allow the same types (which is too restrictive). - What to do about this? - */ - checkExpr[Boolean](lit("a") == tyql.Null, expectN(null))(withDBNoImplicits.all) - checkExpr[Boolean](tyql.Null == lit("a"), expectN(null))(withDBNoImplicits.all) + checkExpr[Boolean](lit("a") == tyql.Null[String], expectN(null))(withDBNoImplicits.all) + checkExpr[Boolean](tyql.Null[String] == lit("a"), expectN(null))(withDBNoImplicits.all) checkExpr[Boolean](tyql.Null == tyql.Null, expectN(null))(withDBNoImplicits.all) } @@ -33,4 +25,28 @@ class NullSafeEqualityTest extends FunSuite { checkExprDialect[Boolean](tyql.Null[String] === lit("a"), expectB(false))(withDB.all) checkExprDialect[Boolean](tyql.Null === tyql.Null, expectB(true))(withDB.all) } + + test("you can use weakly-typed === equality when not on postgres, duckdb, h2") { + { + import Dialect.mysql.given + checkExprDialect[Boolean](lit("a") === 10, expectB(false))(withDB.allmysql) + checkExprDialect[Boolean](lit("a") !== 10, expectB(true))(withDB.allmysql) + checkExprDialect[Boolean](lit("a") == 10, expectB(false))(withDB.allmysql) + checkExprDialect[Boolean](lit("a") != 10, expectB(true))(withDB.allmysql) + } + { + import Dialect.sqlite.given + checkExprDialect[Boolean](lit("a") === 10, expectB(false))(withDB.sqlite) + checkExprDialect[Boolean](lit("a") !== 10, expectB(true))(withDB.sqlite) + checkExprDialect[Boolean](lit("a") == 10, expectB(false))(withDB.sqlite) + checkExprDialect[Boolean](lit("a") != 10, expectB(true))(withDB.sqlite) + } + } + + test("this still works with filters") { + import Dialect.postgresql.given + case class R(kiki: Int) + val t = Table[R]("table") + t.filter(p => p.kiki === 10) + } } From e2016fd45ede21cf8c72f3130c6baae7a8c41694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 26 Nov 2024 20:24:44 +0100 Subject: [PATCH 089/106] H2 does not have weakly-typed equality --- src/main/scala/tyql/dialects/dialects.scala | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index 8b66839..056111d 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -145,7 +145,6 @@ object Dialect: given RandomUUID = new RandomUUID {} given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} - given WeaklyTypedEquality = new WeaklyTypedEquality {} object duckdb: given Dialect = new Dialect From fd3213a55018f61511081bdf02da44e9e1f9731a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 26 Nov 2024 20:29:51 +0100 Subject: [PATCH 090/106] towards per-dialect equality semantics --- src/main/scala/tyql/expr/Expr.scala | 24 +++++++++---------- .../test/integration/null-safe equality.scala | 16 ++++++++++++- .../scala/test/query/AggregationTests.scala | 8 +++---- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index bf1de9b..1d5e1db 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -30,14 +30,14 @@ trait Expr[Result, Shape <: ExprShape](using val tag: ResultTag[Result]) extends def selectDynamic(fieldName: String) = Expr.Select(this, fieldName) /** Member methods to implement universal equality on Expr level. */ - // @targetName("eqNonScalar") - // def ==(other: Expr[Result, NonScalarExpr]): Expr[Boolean, Shape] = Expr.Eq[Shape, NonScalarExpr](this, other) - // @targetName("eqScalar") - // def ==(other: Expr[Result, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.Eq[Shape, ScalarExpr](this, other) + @targetName("eqNonScalar") + def ==(other: Expr[Result, NonScalarExpr]): Expr[Boolean, Shape] = Expr.Eq[Shape, NonScalarExpr](this, other) + @targetName("eqScalar") + def ==(other: Expr[Result, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.Eq[Shape, ScalarExpr](this, other) @targetName("eqNonScalarUniversal") - def ==[T](other: Expr[T, NonScalarExpr])(using ResultTag[T]/*, tyql.DialectFeature.WeaklyTypedEquality*/): Expr[Boolean, Shape] = Expr.Eq[Shape, NonScalarExpr](this, other) + def ==[T](other: Expr[T, NonScalarExpr])(using ResultTag[T], tyql.DialectFeature.WeaklyTypedEquality): Expr[Boolean, Shape] = Expr.Eq[Shape, NonScalarExpr](this, other) @targetName("eqScalarUniversal") - def ==[T](other: Expr[T, ScalarExpr])(using ResultTag[T]/*, tyql.DialectFeature.WeaklyTypedEquality*/): Expr[Boolean, ScalarExpr] = Expr.Eq[Shape, ScalarExpr](this, other) + def ==[T](other: Expr[T, ScalarExpr])(using ResultTag[T], tyql.DialectFeature.WeaklyTypedEquality): Expr[Boolean, ScalarExpr] = Expr.Eq[Shape, ScalarExpr](this, other) @targetName("nullSafeEqNonScalar") def ===(other: Expr[Result, NonScalarExpr]): Expr[Boolean, Shape] = Expr.NullSafeEq[Shape, NonScalarExpr](this, other) @targetName("nullSafeEqScalar") @@ -51,14 +51,14 @@ trait Expr[Result, Shape <: ExprShape](using val tag: ResultTag[Result]) extends def ==(other: Int): Expr[Boolean, Shape] = Expr.Eq(this, Expr.IntLit(other)) def ==(other: Boolean): Expr[Boolean, Shape] = Expr.Eq(this, Expr.BooleanLit(other)) - // @targetName("neqNonScalar") - // def != (other: Expr[Result, NonScalarExpr]): Expr[Boolean, Shape] = Expr.Ne[Shape, NonScalarExpr](this, other) - // @targetName("neqScalar") - // def != (other: Expr[Result, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.Ne[Shape, ScalarExpr](this, other) + @targetName("neqNonScalar") + def != (other: Expr[Result, NonScalarExpr]): Expr[Boolean, Shape] = Expr.Ne[Shape, NonScalarExpr](this, other) + @targetName("neqScalar") + def != (other: Expr[Result, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.Ne[Shape, ScalarExpr](this, other) @targetName("neqNonScalarUniversal") - def != [T](other: Expr[T, NonScalarExpr])(using ResultTag[T]/*, tyql.DialectFeature.WeaklyTypedEquality*/): Expr[Boolean, Shape] = Expr.Ne[Shape, NonScalarExpr](this, other) + def != [T](other: Expr[T, NonScalarExpr])(using ResultTag[T], tyql.DialectFeature.WeaklyTypedEquality): Expr[Boolean, Shape] = Expr.Ne[Shape, NonScalarExpr](this, other) @targetName("neqScalarUniversal") - def != [T](other: Expr[T, ScalarExpr])(using ResultTag[T]/*, tyql.DialectFeature.WeaklyTypedEquality*/): Expr[Boolean, ScalarExpr] = Expr.Ne[Shape, ScalarExpr](this, other) + def != [T](other: Expr[T, ScalarExpr])(using ResultTag[T], tyql.DialectFeature.WeaklyTypedEquality): Expr[Boolean, ScalarExpr] = Expr.Ne[Shape, ScalarExpr](this, other) @targetName("nullSafeNeqNonScalar") def !== (other: Expr[Result, NonScalarExpr]): Expr[Boolean, Shape] = Expr.NullSafeNe[Shape, NonScalarExpr](this, other) @targetName("nullSafeNeqScalar") diff --git a/src/test/scala/test/integration/null-safe equality.scala b/src/test/scala/test/integration/null-safe equality.scala index d2d7028..7841fe9 100644 --- a/src/test/scala/test/integration/null-safe equality.scala +++ b/src/test/scala/test/integration/null-safe equality.scala @@ -43,10 +43,24 @@ class NullSafeEqualityTest extends FunSuite { } } - test("this still works with filters") { + test("this still works with filters (postgresql)") { import Dialect.postgresql.given case class R(kiki: Int) val t = Table[R]("table") t.filter(p => p.kiki === 10) + t.filter(p => p.kiki == 10) + // t.filter(p => p.kiki !== "a") + // t.filter(p => p.kiki != "a") + // t.filter(p => p.kiki == 1.0) // (!) currently this is also unsupported because Int != Double + } + + test("this still works with filters (mysql)") { + import Dialect.mysql.given + case class R(kiki: Int) + val t = Table[R]("table") + t.filter(p => p.kiki === 10) + t.filter(p => p.kiki == 10) + t.filter(p => p.kiki !== "a") + t.filter(p => p.kiki != "a") } } diff --git a/src/test/scala/test/query/AggregationTests.scala b/src/test/scala/test/query/AggregationTests.scala index e61cc6d..fe8eeec 100644 --- a/src/test/scala/test/query/AggregationTests.scala +++ b/src/test/scala/test/query/AggregationTests.scala @@ -61,7 +61,7 @@ class AggregateMultiAggregateTest extends SQLStringAggregationTest[AllCommerceDB def query() = testDB.tables.products .withFilter(p => - p.price != 0 + p.price != 0.0 ) .aggregate(p => (sum = sum(p.price), avg = avg(p.price)).toRow) @@ -113,7 +113,7 @@ class FilterAggregationQueryTest extends SQLStringAggregationTest[AllCommerceDBs def query() = testDB.tables.products .withFilter(p => - p.price != 0 + p.price != 0.0 ) .sum(p => p.price) @@ -127,7 +127,7 @@ class FilterAggregationProjectQueryTest extends SQLStringAggregationTest[AllComm def query() = testDB.tables.products .withFilter(p => - p.price != 0 + p.price != 0.0 ) .sum(p => (sum = p.price).toRow @@ -142,7 +142,7 @@ class FilterMapAggregationQuerySelectTest extends SQLStringAggregationTest[AllCo def testDescription: String = "Aggregation: sum with map" def query() = testDB.tables.products - .withFilter(p => p.price != 0) + .withFilter(p => p.price != 0.0) .map(p => (newPrice = p.price).toRow) .sum(_.newPrice) def expectedQueryPattern: String = """ From eef06ff90e3b558ff7dc83f1271c435e06047726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 26 Nov 2024 20:37:36 +0100 Subject: [PATCH 091/106] fix failing tests --- src/test/scala/test/query/AggregationTests.scala | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/test/scala/test/query/AggregationTests.scala b/src/test/scala/test/query/AggregationTests.scala index fe8eeec..e9ab3e2 100644 --- a/src/test/scala/test/query/AggregationTests.scala +++ b/src/test/scala/test/query/AggregationTests.scala @@ -66,8 +66,8 @@ class AggregateMultiAggregateTest extends SQLStringAggregationTest[AllCommerceDB .aggregate(p => (sum = sum(p.price), avg = avg(p.price)).toRow) def expectedQueryPattern: String = - """SELECT SUM(product$A.price) as sum, AVG(product$A.price) as avg FROM product as product$A WHERE product$A.price <> 0 - """ + """SELECT SUM(product$A.price) as sum, AVG(product$A.price) as avg FROM product as product$A WHERE product$A.price <> 0.0 + """ // TODO should pass with just `0` } class AggregateMultiSubexpressionAggregateTest extends SQLStringAggregationTest[AllCommerceDBs, (sum: Boolean, avg: Boolean)] { @@ -118,8 +118,8 @@ class FilterAggregationQueryTest extends SQLStringAggregationTest[AllCommerceDBs .sum(p => p.price) def expectedQueryPattern: String = """ - SELECT SUM(product$A.price) FROM product as product$A WHERE product$A.price <> 0 - """ + SELECT SUM(product$A.price) FROM product as product$A WHERE product$A.price <> 0.0 + """ // TODO should pass with just `0` } class FilterAggregationProjectQueryTest extends SQLStringAggregationTest[AllCommerceDBs, (sum: Double)] { @@ -134,8 +134,8 @@ class FilterAggregationProjectQueryTest extends SQLStringAggregationTest[AllComm ) def expectedQueryPattern: String = """ - SELECT SUM(product$A.price as sum) FROM product as product$A WHERE product$A.price <> 0 - """ + SELECT SUM(product$A.price as sum) FROM product as product$A WHERE product$A.price <> 0.0 + """ // TODO should pass with just `0` } class FilterMapAggregationQuerySelectTest extends SQLStringAggregationTest[AllCommerceDBs, Double] { @@ -146,8 +146,8 @@ class FilterMapAggregationQuerySelectTest extends SQLStringAggregationTest[AllCo .map(p => (newPrice = p.price).toRow) .sum(_.newPrice) def expectedQueryPattern: String = """ -SELECT SUM(subquery$A.newPrice) FROM (SELECT product$B.price as newPrice FROM product as product$B WHERE product$B.price <> 0) as subquery$A - """ +SELECT SUM(subquery$A.newPrice) FROM (SELECT product$B.price as newPrice FROM product as product$B WHERE product$B.price <> 0.0) as subquery$A + """ // TODO should pass with just `0` } class AggregationSubqueryTest extends SQLStringQueryTest[AllCommerceDBs, Boolean] { From 28af74309fb2e7e58df90c3e1ff26ade0d9e6297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 26 Nov 2024 21:42:06 +0100 Subject: [PATCH 092/106] equality semantics is now fully dialect-specific --- .../main/scala/QueryBenchmark/ASPSQuery.scala | 2 + .../scala/QueryBenchmark/AncestryQuery.scala | 2 + .../scala/QueryBenchmark/AndersensQuery.scala | 2 + .../main/scala/QueryBenchmark/BOMQuery.scala | 2 + .../main/scala/QueryBenchmark/CBAQuery.scala | 2 + .../main/scala/QueryBenchmark/CSPAQuery.scala | 2 + .../QueryBenchmark/CompanyControlQuery.scala | 2 + .../scala/QueryBenchmark/DataflowQuery.scala | 2 + .../scala/QueryBenchmark/EvenOddQuery.scala | 2 + .../scala/QueryBenchmark/JavaPointsTo.scala | 2 + .../scala/QueryBenchmark/OrbitsQuery.scala | 2 + .../scala/QueryBenchmark/PartyQuery.scala | 2 + .../QueryBenchmark/PointsToCountQuery.scala | 2 + .../main/scala/QueryBenchmark/SSSPQuery.scala | 2 + .../main/scala/QueryBenchmark/TCQuery.scala | 2 + .../QueryBenchmark/TrustChainQuery.scala | 2 + .../TimeoutQueryBenchmark/ASPSQuery.scala | 2 + .../TimeoutQueryBenchmark/AncestryQuery.scala | 2 + .../AndersensQuery.scala | 2 + .../TimeoutQueryBenchmark/BOMQuery.scala | 2 + .../TimeoutQueryBenchmark/CBAQuery.scala | 2 + .../TimeoutQueryBenchmark/CSPAQuery.scala | 2 + .../CompanyControlQuery.scala | 2 + .../TimeoutQueryBenchmark/DataflowQuery.scala | 2 + .../TimeoutQueryBenchmark/EvenOddQuery.scala | 2 + .../TimeoutQueryBenchmark/JavaPointsTo.scala | 2 + .../TimeoutQueryBenchmark/OrbitsQuery.scala | 2 + .../TimeoutQueryBenchmark/PartyQuery.scala | 2 + .../PointsToCountQuery.scala | 2 + .../TimeoutQueryBenchmark/SSSPQuery.scala | 2 + .../scala/TimeoutQueryBenchmark/TCQuery.scala | 2 + .../TrustChainQuery.scala | 2 + .../tyql/dialects/dialect features.scala | 2 - src/main/scala/tyql/dialects/dialects.scala | 21 +++++++++-- src/main/scala/tyql/expr/Expr.scala | 37 ++++--------------- src/test/scala/test/integration/cases.scala | 2 + .../test/integration/null-safe equality.scala | 21 ++++++++++- .../scala/test/integration/precedence.scala | 2 + .../scala/test/query/AggregationTests.scala | 2 + src/test/scala/test/query/FailTests.scala | 2 + src/test/scala/test/query/FlowTests.scala | 2 + src/test/scala/test/query/GroupByTests.scala | 2 + src/test/scala/test/query/JoinTests.scala | 1 + .../test/query/RecursiveBenchmarkTests.scala | 2 + .../test/query/RecursiveConstraintTests.scala | 2 + .../scala/test/query/RecursiveTests.scala | 2 + src/test/scala/test/query/ScopeTests.scala | 2 + src/test/scala/test/query/SelectTests.scala | 2 + src/test/scala/test/query/SubqueryTests.scala | 2 + 49 files changed, 135 insertions(+), 35 deletions(-) diff --git a/bench/src/main/scala/QueryBenchmark/ASPSQuery.scala b/bench/src/main/scala/QueryBenchmark/ASPSQuery.scala index 3fb77fb..b016445 100644 --- a/bench/src/main/scala/QueryBenchmark/ASPSQuery.scala +++ b/bench/src/main/scala/QueryBenchmark/ASPSQuery.scala @@ -14,6 +14,8 @@ import scala.NamedTuple.* import tyql.{Ord, Table} import tyql.Expr.min +import tyql.Dialect.ansi.given + @experimental class ASPSQuery extends QueryBenchmark { override def name = "asps" diff --git a/bench/src/main/scala/QueryBenchmark/AncestryQuery.scala b/bench/src/main/scala/QueryBenchmark/AncestryQuery.scala index eec1554..dbe8697 100644 --- a/bench/src/main/scala/QueryBenchmark/AncestryQuery.scala +++ b/bench/src/main/scala/QueryBenchmark/AncestryQuery.scala @@ -14,6 +14,8 @@ import scala.NamedTuple.* import tyql.{Ord, Table} import tyql.Expr.{IntLit, min} +import tyql.Dialect.ansi.given + @experimental class AncestryQuery extends QueryBenchmark { override def name = "ancestry" diff --git a/bench/src/main/scala/QueryBenchmark/AndersensQuery.scala b/bench/src/main/scala/QueryBenchmark/AndersensQuery.scala index 643f421..1dbddd0 100644 --- a/bench/src/main/scala/QueryBenchmark/AndersensQuery.scala +++ b/bench/src/main/scala/QueryBenchmark/AndersensQuery.scala @@ -14,6 +14,8 @@ import scala.NamedTuple.* import tyql.{Ord, Table} import tyql.Expr.{IntLit, min} +import tyql.Dialect.ansi.given + @experimental class AndersensQuery extends QueryBenchmark { override def name = "andersens" diff --git a/bench/src/main/scala/QueryBenchmark/BOMQuery.scala b/bench/src/main/scala/QueryBenchmark/BOMQuery.scala index 50b3248..a5a563d 100644 --- a/bench/src/main/scala/QueryBenchmark/BOMQuery.scala +++ b/bench/src/main/scala/QueryBenchmark/BOMQuery.scala @@ -14,6 +14,8 @@ import scala.NamedTuple.* import tyql.{Ord, Table} import tyql.Expr.max +import tyql.Dialect.ansi.given + @experimental class BOMQuery extends QueryBenchmark { override def name = "bom" diff --git a/bench/src/main/scala/QueryBenchmark/CBAQuery.scala b/bench/src/main/scala/QueryBenchmark/CBAQuery.scala index 028c925..d827c21 100644 --- a/bench/src/main/scala/QueryBenchmark/CBAQuery.scala +++ b/bench/src/main/scala/QueryBenchmark/CBAQuery.scala @@ -15,6 +15,8 @@ import tyql.Query.{unrestrictedBagFix, unrestrictedFix} import tyql.Expr.{IntLit, StringLit, min} import Helpers.* +import tyql.Dialect.ansi.given + @experimental class CBAQuery extends QueryBenchmark { override def name = "cba" diff --git a/bench/src/main/scala/QueryBenchmark/CSPAQuery.scala b/bench/src/main/scala/QueryBenchmark/CSPAQuery.scala index a8cd264..68dcdd7 100644 --- a/bench/src/main/scala/QueryBenchmark/CSPAQuery.scala +++ b/bench/src/main/scala/QueryBenchmark/CSPAQuery.scala @@ -15,6 +15,8 @@ import tyql.Query.unrestrictedFix import tyql.Expr.{IntLit, StringLit, min} import Helpers.* +import tyql.Dialect.ansi.given + @experimental class CSPAQuery extends QueryBenchmark { override def name = "cspa" diff --git a/bench/src/main/scala/QueryBenchmark/CompanyControlQuery.scala b/bench/src/main/scala/QueryBenchmark/CompanyControlQuery.scala index 47b3f58..84f5799 100644 --- a/bench/src/main/scala/QueryBenchmark/CompanyControlQuery.scala +++ b/bench/src/main/scala/QueryBenchmark/CompanyControlQuery.scala @@ -15,6 +15,8 @@ import tyql.Query.{fix, unrestrictedFix} import tyql.Expr.{IntLit, StringLit, min, sum} import Helpers.* +import tyql.Dialect.ansi.given + @experimental class CompanyControlQuery extends QueryBenchmark { override def name = "cc" diff --git a/bench/src/main/scala/QueryBenchmark/DataflowQuery.scala b/bench/src/main/scala/QueryBenchmark/DataflowQuery.scala index 6fed950..e9d6503 100644 --- a/bench/src/main/scala/QueryBenchmark/DataflowQuery.scala +++ b/bench/src/main/scala/QueryBenchmark/DataflowQuery.scala @@ -14,6 +14,8 @@ import scala.NamedTuple.* import tyql.{Ord, Table} import tyql.Expr.{IntLit, min} +import tyql.Dialect.ansi.given + @experimental class DataflowQuery extends QueryBenchmark { override def name = "dataflow" diff --git a/bench/src/main/scala/QueryBenchmark/EvenOddQuery.scala b/bench/src/main/scala/QueryBenchmark/EvenOddQuery.scala index f3ff1e5..14fad76 100644 --- a/bench/src/main/scala/QueryBenchmark/EvenOddQuery.scala +++ b/bench/src/main/scala/QueryBenchmark/EvenOddQuery.scala @@ -15,6 +15,8 @@ import tyql.Query.fix import tyql.Expr.{IntLit, StringLit, min} import Helpers.* +import tyql.Dialect.ansi.given + @experimental class EvenOddQuery extends QueryBenchmark { override def name = "evenodd" diff --git a/bench/src/main/scala/QueryBenchmark/JavaPointsTo.scala b/bench/src/main/scala/QueryBenchmark/JavaPointsTo.scala index 28b4a6e..66450b9 100644 --- a/bench/src/main/scala/QueryBenchmark/JavaPointsTo.scala +++ b/bench/src/main/scala/QueryBenchmark/JavaPointsTo.scala @@ -15,6 +15,8 @@ import tyql.Query.{fix, unrestrictedFix, unrestrictedBagFix} import tyql.Expr.{IntLit, StringLit, min, sum} import Helpers.* +import tyql.Dialect.ansi.given + @experimental class JavaPointsTo extends QueryBenchmark { override def name = "javapointsto" diff --git a/bench/src/main/scala/QueryBenchmark/OrbitsQuery.scala b/bench/src/main/scala/QueryBenchmark/OrbitsQuery.scala index 53c9652..7ebf401 100644 --- a/bench/src/main/scala/QueryBenchmark/OrbitsQuery.scala +++ b/bench/src/main/scala/QueryBenchmark/OrbitsQuery.scala @@ -14,6 +14,8 @@ import scala.NamedTuple.* import tyql.{Ord, Table, Query} import tyql.Expr.max +import tyql.Dialect.ansi.given + @experimental class OrbitsQuery extends QueryBenchmark { override def name = "orbits" diff --git a/bench/src/main/scala/QueryBenchmark/PartyQuery.scala b/bench/src/main/scala/QueryBenchmark/PartyQuery.scala index 2e6d332..29e4ffe 100644 --- a/bench/src/main/scala/QueryBenchmark/PartyQuery.scala +++ b/bench/src/main/scala/QueryBenchmark/PartyQuery.scala @@ -15,6 +15,8 @@ import tyql.Query.{unrestrictedBagFix, unrestrictedFix} import tyql.Expr.{IntLit, StringLit, count} import Helpers.* +import tyql.Dialect.ansi.given + @experimental class PartyQuery extends QueryBenchmark { override def name = "party" diff --git a/bench/src/main/scala/QueryBenchmark/PointsToCountQuery.scala b/bench/src/main/scala/QueryBenchmark/PointsToCountQuery.scala index a6b7da4..1ad223c 100644 --- a/bench/src/main/scala/QueryBenchmark/PointsToCountQuery.scala +++ b/bench/src/main/scala/QueryBenchmark/PointsToCountQuery.scala @@ -15,6 +15,8 @@ import tyql.Query.{fix, unrestrictedFix} import tyql.Expr.{IntLit, StringLit, min, sum} import Helpers.* +import tyql.Dialect.ansi.given + @experimental class PointsToCountQuery extends QueryBenchmark { override def name = "pointstocount" diff --git a/bench/src/main/scala/QueryBenchmark/SSSPQuery.scala b/bench/src/main/scala/QueryBenchmark/SSSPQuery.scala index 2f34665..489040c 100644 --- a/bench/src/main/scala/QueryBenchmark/SSSPQuery.scala +++ b/bench/src/main/scala/QueryBenchmark/SSSPQuery.scala @@ -14,6 +14,8 @@ import scala.NamedTuple.* import tyql.{Ord, Table} import tyql.Expr.min +import tyql.Dialect.ansi.given + @experimental class SSSPQuery extends QueryBenchmark { override def name = "sssp" diff --git a/bench/src/main/scala/QueryBenchmark/TCQuery.scala b/bench/src/main/scala/QueryBenchmark/TCQuery.scala index c9dbaa0..9220216 100644 --- a/bench/src/main/scala/QueryBenchmark/TCQuery.scala +++ b/bench/src/main/scala/QueryBenchmark/TCQuery.scala @@ -13,6 +13,8 @@ import scalasql.{Table as ScalaSQLTable, Expr, query} import scalasql.PostgresDialect.* import scalasql.core.SqlStr.SqlStringSyntax +import tyql.Dialect.ansi.given + @experimental class TCQuery extends QueryBenchmark { override def name = "tc" diff --git a/bench/src/main/scala/QueryBenchmark/TrustChainQuery.scala b/bench/src/main/scala/QueryBenchmark/TrustChainQuery.scala index abf81a8..a56e31b 100644 --- a/bench/src/main/scala/QueryBenchmark/TrustChainQuery.scala +++ b/bench/src/main/scala/QueryBenchmark/TrustChainQuery.scala @@ -15,6 +15,8 @@ import tyql.Query.{unrestrictedBagFix, unrestrictedFix} import tyql.Expr.{IntLit, StringLit, count} import Helpers.* +import tyql.Dialect.ansi.given + @experimental class TrustChainQuery extends QueryBenchmark { override def name = "trustchain" diff --git a/bench/src/main/scala/TimeoutQueryBenchmark/ASPSQuery.scala b/bench/src/main/scala/TimeoutQueryBenchmark/ASPSQuery.scala index 5c9f74e..460b6ab 100644 --- a/bench/src/main/scala/TimeoutQueryBenchmark/ASPSQuery.scala +++ b/bench/src/main/scala/TimeoutQueryBenchmark/ASPSQuery.scala @@ -14,6 +14,8 @@ import scala.NamedTuple.* import tyql.{Ord, Table} import tyql.Expr.min +import tyql.Dialect.ansi.given + @experimental class TOASPSQuery extends QueryBenchmark { override def name = "asps" diff --git a/bench/src/main/scala/TimeoutQueryBenchmark/AncestryQuery.scala b/bench/src/main/scala/TimeoutQueryBenchmark/AncestryQuery.scala index 06eb0b0..7dd9793 100644 --- a/bench/src/main/scala/TimeoutQueryBenchmark/AncestryQuery.scala +++ b/bench/src/main/scala/TimeoutQueryBenchmark/AncestryQuery.scala @@ -14,6 +14,8 @@ import scala.NamedTuple.* import tyql.{Ord, Table} import tyql.Expr.{IntLit, min} +import tyql.Dialect.ansi.given + @experimental class TOAncestryQuery extends QueryBenchmark { override def name = "ancestry" diff --git a/bench/src/main/scala/TimeoutQueryBenchmark/AndersensQuery.scala b/bench/src/main/scala/TimeoutQueryBenchmark/AndersensQuery.scala index 6b2f28a..8c44cc0 100644 --- a/bench/src/main/scala/TimeoutQueryBenchmark/AndersensQuery.scala +++ b/bench/src/main/scala/TimeoutQueryBenchmark/AndersensQuery.scala @@ -14,6 +14,8 @@ import scala.NamedTuple.* import tyql.{Ord, Table} import tyql.Expr.{IntLit, min} +import tyql.Dialect.ansi.given + @experimental class TOAndersensQuery extends QueryBenchmark { override def name = "andersens" diff --git a/bench/src/main/scala/TimeoutQueryBenchmark/BOMQuery.scala b/bench/src/main/scala/TimeoutQueryBenchmark/BOMQuery.scala index bff20be..98a08c3 100644 --- a/bench/src/main/scala/TimeoutQueryBenchmark/BOMQuery.scala +++ b/bench/src/main/scala/TimeoutQueryBenchmark/BOMQuery.scala @@ -14,6 +14,8 @@ import scala.NamedTuple.* import tyql.{Ord, Table} import tyql.Expr.max +import tyql.Dialect.ansi.given + @experimental class TOBOMQuery extends QueryBenchmark { override def name = "bom" diff --git a/bench/src/main/scala/TimeoutQueryBenchmark/CBAQuery.scala b/bench/src/main/scala/TimeoutQueryBenchmark/CBAQuery.scala index 07634b9..8d05604 100644 --- a/bench/src/main/scala/TimeoutQueryBenchmark/CBAQuery.scala +++ b/bench/src/main/scala/TimeoutQueryBenchmark/CBAQuery.scala @@ -15,6 +15,8 @@ import tyql.Query.{unrestrictedBagFix, unrestrictedFix} import tyql.Expr.{IntLit, StringLit, min} import Helpers.* +import tyql.Dialect.ansi.given + @experimental class TOCBAQuery extends QueryBenchmark { override def name = "cba" diff --git a/bench/src/main/scala/TimeoutQueryBenchmark/CSPAQuery.scala b/bench/src/main/scala/TimeoutQueryBenchmark/CSPAQuery.scala index 755d186..b68f7b8 100644 --- a/bench/src/main/scala/TimeoutQueryBenchmark/CSPAQuery.scala +++ b/bench/src/main/scala/TimeoutQueryBenchmark/CSPAQuery.scala @@ -15,6 +15,8 @@ import tyql.Query.unrestrictedFix import tyql.Expr.{IntLit, StringLit, min} import Helpers.* +import tyql.Dialect.ansi.given + @experimental class TOCSPAQuery extends QueryBenchmark { override def name = "cspa" diff --git a/bench/src/main/scala/TimeoutQueryBenchmark/CompanyControlQuery.scala b/bench/src/main/scala/TimeoutQueryBenchmark/CompanyControlQuery.scala index 92b6216..7198b47 100644 --- a/bench/src/main/scala/TimeoutQueryBenchmark/CompanyControlQuery.scala +++ b/bench/src/main/scala/TimeoutQueryBenchmark/CompanyControlQuery.scala @@ -15,6 +15,8 @@ import tyql.Query.{fix, unrestrictedFix} import tyql.Expr.{IntLit, StringLit, min, sum} import Helpers.* +import tyql.Dialect.ansi.given + @experimental class TOCompanyControlQuery extends QueryBenchmark { override def name = "cc" diff --git a/bench/src/main/scala/TimeoutQueryBenchmark/DataflowQuery.scala b/bench/src/main/scala/TimeoutQueryBenchmark/DataflowQuery.scala index 3004efc..ba3c07a 100644 --- a/bench/src/main/scala/TimeoutQueryBenchmark/DataflowQuery.scala +++ b/bench/src/main/scala/TimeoutQueryBenchmark/DataflowQuery.scala @@ -14,6 +14,8 @@ import scala.NamedTuple.* import tyql.{Ord, Table} import tyql.Expr.{IntLit, min} +import tyql.Dialect.ansi.given + @experimental class TODataflowQuery extends QueryBenchmark { override def name = "dataflow" diff --git a/bench/src/main/scala/TimeoutQueryBenchmark/EvenOddQuery.scala b/bench/src/main/scala/TimeoutQueryBenchmark/EvenOddQuery.scala index 45bd50d..590f52b 100644 --- a/bench/src/main/scala/TimeoutQueryBenchmark/EvenOddQuery.scala +++ b/bench/src/main/scala/TimeoutQueryBenchmark/EvenOddQuery.scala @@ -15,6 +15,8 @@ import tyql.Query.fix import tyql.Expr.{IntLit, StringLit, min} import Helpers.* +import tyql.Dialect.ansi.given + @experimental class TOEvenOddQuery extends QueryBenchmark { override def name = "evenodd" diff --git a/bench/src/main/scala/TimeoutQueryBenchmark/JavaPointsTo.scala b/bench/src/main/scala/TimeoutQueryBenchmark/JavaPointsTo.scala index 6282bd5..580448d 100644 --- a/bench/src/main/scala/TimeoutQueryBenchmark/JavaPointsTo.scala +++ b/bench/src/main/scala/TimeoutQueryBenchmark/JavaPointsTo.scala @@ -15,6 +15,8 @@ import tyql.Query.{fix, unrestrictedFix, unrestrictedBagFix} import tyql.Expr.{IntLit, StringLit, min, sum} import Helpers.* +import tyql.Dialect.ansi.given + @experimental class TOJavaPointsTo extends QueryBenchmark { override def name = "javapointsto" diff --git a/bench/src/main/scala/TimeoutQueryBenchmark/OrbitsQuery.scala b/bench/src/main/scala/TimeoutQueryBenchmark/OrbitsQuery.scala index 777317b..72150e1 100644 --- a/bench/src/main/scala/TimeoutQueryBenchmark/OrbitsQuery.scala +++ b/bench/src/main/scala/TimeoutQueryBenchmark/OrbitsQuery.scala @@ -14,6 +14,8 @@ import scala.NamedTuple.* import tyql.{Ord, Table, Query} import tyql.Expr.max +import tyql.Dialect.ansi.given + @experimental class TOOrbitsQuery extends QueryBenchmark { override def name = "orbits" diff --git a/bench/src/main/scala/TimeoutQueryBenchmark/PartyQuery.scala b/bench/src/main/scala/TimeoutQueryBenchmark/PartyQuery.scala index 400e6a7..a7de290 100644 --- a/bench/src/main/scala/TimeoutQueryBenchmark/PartyQuery.scala +++ b/bench/src/main/scala/TimeoutQueryBenchmark/PartyQuery.scala @@ -15,6 +15,8 @@ import tyql.Query.{unrestrictedBagFix, unrestrictedFix} import tyql.Expr.{IntLit, StringLit, count} import Helpers.* +import tyql.Dialect.ansi.given + @experimental class TOPartyQuery extends QueryBenchmark { override def name = "party" diff --git a/bench/src/main/scala/TimeoutQueryBenchmark/PointsToCountQuery.scala b/bench/src/main/scala/TimeoutQueryBenchmark/PointsToCountQuery.scala index 19a0cad..ea8ab71 100644 --- a/bench/src/main/scala/TimeoutQueryBenchmark/PointsToCountQuery.scala +++ b/bench/src/main/scala/TimeoutQueryBenchmark/PointsToCountQuery.scala @@ -15,6 +15,8 @@ import tyql.Query.{fix, unrestrictedFix} import tyql.Expr.{IntLit, StringLit, min, sum} import Helpers.* +import tyql.Dialect.ansi.given + @experimental class TOPointsToCountQuery extends QueryBenchmark { override def name = "pointstocount" diff --git a/bench/src/main/scala/TimeoutQueryBenchmark/SSSPQuery.scala b/bench/src/main/scala/TimeoutQueryBenchmark/SSSPQuery.scala index 39338ea..8c4fb87 100644 --- a/bench/src/main/scala/TimeoutQueryBenchmark/SSSPQuery.scala +++ b/bench/src/main/scala/TimeoutQueryBenchmark/SSSPQuery.scala @@ -14,6 +14,8 @@ import scala.NamedTuple.* import tyql.{Ord, Table} import tyql.Expr.min +import tyql.Dialect.ansi.given + @experimental class TOSSSPQuery extends QueryBenchmark { override def name = "sssp" diff --git a/bench/src/main/scala/TimeoutQueryBenchmark/TCQuery.scala b/bench/src/main/scala/TimeoutQueryBenchmark/TCQuery.scala index f79d491..fb6142d 100644 --- a/bench/src/main/scala/TimeoutQueryBenchmark/TCQuery.scala +++ b/bench/src/main/scala/TimeoutQueryBenchmark/TCQuery.scala @@ -13,6 +13,8 @@ import scalasql.{Table as ScalaSQLTable, Expr, query} import scalasql.PostgresDialect.* import scalasql.core.SqlStr.SqlStringSyntax +import tyql.Dialect.ansi.given + @experimental class TOTCQuery extends QueryBenchmark { override def name = "tc" diff --git a/bench/src/main/scala/TimeoutQueryBenchmark/TrustChainQuery.scala b/bench/src/main/scala/TimeoutQueryBenchmark/TrustChainQuery.scala index 5e02884..8b77a68 100644 --- a/bench/src/main/scala/TimeoutQueryBenchmark/TrustChainQuery.scala +++ b/bench/src/main/scala/TimeoutQueryBenchmark/TrustChainQuery.scala @@ -15,6 +15,8 @@ import tyql.Query.{unrestrictedBagFix, unrestrictedFix} import tyql.Expr.{IntLit, StringLit, count} import Helpers.* +import tyql.Dialect.ansi.given + @experimental class TOTrustChainQuery extends QueryBenchmark { override def name = "trustchain" diff --git a/src/main/scala/tyql/dialects/dialect features.scala b/src/main/scala/tyql/dialects/dialect features.scala index a13b82b..a4342be 100644 --- a/src/main/scala/tyql/dialects/dialect features.scala +++ b/src/main/scala/tyql/dialects/dialect features.scala @@ -7,5 +7,3 @@ object DialectFeature: trait RandomIntegerInInclusiveRange extends DialectFeature trait ReversibleStrings extends DialectFeature - - trait WeaklyTypedEquality extends DialectFeature diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index 056111d..0f342b2 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -49,6 +49,7 @@ object Dialect: object ansi: given Dialect = Dialect.given_Dialect + given [T: ResultTag]: CanBeEqualed[T, T] = new CanBeEqualed[T, T] {} object postgresql: given Dialect = new Dialect @@ -70,6 +71,11 @@ object Dialect: given RandomUUID = new RandomUUID {} given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} given ReversibleStrings = new ReversibleStrings {} + given [T: ResultTag]: CanBeEqualed[T, T] = new CanBeEqualed[T, T] {} + // TODO later support more options here (?) + given CanBeEqualed[Double, Int] = new CanBeEqualed[Double, Int] {} + given CanBeEqualed[Int, Double] = new CanBeEqualed[Int, Double] {} + object mysql: given Dialect = new MySQLDialect @@ -92,7 +98,7 @@ object Dialect: given RandomUUID = new RandomUUID {} given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} given ReversibleStrings = new ReversibleStrings {} - given WeaklyTypedEquality = new WeaklyTypedEquality {} + given [T1, T2]: CanBeEqualed[T1, T2] = new CanBeEqualed[T1, T2] {} object mariadb: // XXX MariaDB extends MySQL @@ -103,7 +109,7 @@ object Dialect: given RandomUUID = mysql.given_RandomUUID given RandomIntegerInInclusiveRange = mysql.given_RandomIntegerInInclusiveRange given ReversibleStrings = mysql.given_ReversibleStrings - given WeaklyTypedEquality = mysql.given_WeaklyTypedEquality + given [T1, T2]: CanBeEqualed[T1, T2] = new CanBeEqualed[T1, T2] {} object sqlite: given Dialect = new Dialect @@ -125,7 +131,7 @@ object Dialect: given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} given ReversibleStrings = new ReversibleStrings {} - given WeaklyTypedEquality = new WeaklyTypedEquality {} + given [T1, T2]: CanBeEqualed[T1, T2] = new CanBeEqualed[T1, T2] {} object h2: given Dialect = new Dialect @@ -145,6 +151,11 @@ object Dialect: given RandomUUID = new RandomUUID {} given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} + given [T: ResultTag]: CanBeEqualed[T, T] = new CanBeEqualed[T, T] {} + // TODO later support more options here (?) + given CanBeEqualed[Double, Int] = new CanBeEqualed[Double, Int] {} + given CanBeEqualed[Int, Double] = new CanBeEqualed[Int, Double] {} + object duckdb: given Dialect = new Dialect @@ -167,6 +178,10 @@ object Dialect: given RandomUUID = new RandomUUID {} given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} given ReversibleStrings = new ReversibleStrings {} + given [T: ResultTag]: CanBeEqualed[T, T] = new CanBeEqualed[T, T] {} + // TODO later support more options here (?) + given CanBeEqualed[Double, Int] = new CanBeEqualed[Double, Int] {} + given CanBeEqualed[Int, Double] = new CanBeEqualed[Int, Double] {} // TODO I currenly have no better idea for this, maybe some macro? var polyfillWasUsed: Function0[Unit] = () => () diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index 1d5e1db..06fc116 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -16,6 +16,8 @@ type CalculatedShape[S1 <: ExprShape, S2 <: ExprShape] <: ExprShape = S2 match case ScalarExpr => S2 case NonScalarExpr => S1 +trait CanBeEqualed[T1, T2] + /** The type of expressions in the query language */ trait Expr[Result, Shape <: ExprShape](using val tag: ResultTag[Result]) extends Selectable: /** This type is used to support selection with any of the field names @@ -30,43 +32,20 @@ trait Expr[Result, Shape <: ExprShape](using val tag: ResultTag[Result]) extends def selectDynamic(fieldName: String) = Expr.Select(this, fieldName) /** Member methods to implement universal equality on Expr level. */ - @targetName("eqNonScalar") - def ==(other: Expr[Result, NonScalarExpr]): Expr[Boolean, Shape] = Expr.Eq[Shape, NonScalarExpr](this, other) - @targetName("eqScalar") - def ==(other: Expr[Result, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.Eq[Shape, ScalarExpr](this, other) - @targetName("eqNonScalarUniversal") - def ==[T](other: Expr[T, NonScalarExpr])(using ResultTag[T], tyql.DialectFeature.WeaklyTypedEquality): Expr[Boolean, Shape] = Expr.Eq[Shape, NonScalarExpr](this, other) - @targetName("eqScalarUniversal") - def ==[T](other: Expr[T, ScalarExpr])(using ResultTag[T], tyql.DialectFeature.WeaklyTypedEquality): Expr[Boolean, ScalarExpr] = Expr.Eq[Shape, ScalarExpr](this, other) - @targetName("nullSafeEqNonScalar") - def ===(other: Expr[Result, NonScalarExpr]): Expr[Boolean, Shape] = Expr.NullSafeEq[Shape, NonScalarExpr](this, other) - @targetName("nullSafeEqScalar") - def ===(other: Expr[Result, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.NullSafeEq[Shape, ScalarExpr](this, other) - @targetName("nullSafeEqNonScalarUniversal") - def ===[T](other: Expr[T, NonScalarExpr])(using ResultTag[T], tyql.DialectFeature.WeaklyTypedEquality): Expr[Boolean, Shape] = Expr.NullSafeEq[Shape, NonScalarExpr](this, other) - @targetName("nullSafeEqScalarUniversal") - def ===[T](other: Expr[T, ScalarExpr])(using ResultTag[T], tyql.DialectFeature.WeaklyTypedEquality): Expr[Boolean, ScalarExpr] = Expr.NullSafeEq[Shape, ScalarExpr](this, other) -// def == [S <: ScalarExpr](other: Expr[?, S]): Expr[Boolean, CalculatedShape[Shape, S]] = Expr.Eq(this, other) + def ==[T, S <: ExprShape](other: Expr[T, S])(using CanBeEqualed[Result, T]): Expr[Boolean, CalculatedShape[Shape, S]] = Expr.Eq[Shape, S](this, other) + def ===[T, S <: ExprShape](other: Expr[T, S])(using CanBeEqualed[Result, T]): Expr[Boolean, CalculatedShape[Shape, S]] = Expr.NullSafeEq[Shape, S](this, other) def ==(other: String): Expr[Boolean, Shape] = Expr.Eq(this, Expr.StringLit(other)) def ==(other: Int): Expr[Boolean, Shape] = Expr.Eq(this, Expr.IntLit(other)) def ==(other: Boolean): Expr[Boolean, Shape] = Expr.Eq(this, Expr.BooleanLit(other)) @targetName("neqNonScalar") - def != (other: Expr[Result, NonScalarExpr]): Expr[Boolean, Shape] = Expr.Ne[Shape, NonScalarExpr](this, other) + def != [T](other: Expr[T, NonScalarExpr])(using CanBeEqualed[Result, T]): Expr[Boolean, Shape] = Expr.Ne[Shape, NonScalarExpr](this, other) @targetName("neqScalar") - def != (other: Expr[Result, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.Ne[Shape, ScalarExpr](this, other) - @targetName("neqNonScalarUniversal") - def != [T](other: Expr[T, NonScalarExpr])(using ResultTag[T], tyql.DialectFeature.WeaklyTypedEquality): Expr[Boolean, Shape] = Expr.Ne[Shape, NonScalarExpr](this, other) - @targetName("neqScalarUniversal") - def != [T](other: Expr[T, ScalarExpr])(using ResultTag[T], tyql.DialectFeature.WeaklyTypedEquality): Expr[Boolean, ScalarExpr] = Expr.Ne[Shape, ScalarExpr](this, other) + def != [T](other: Expr[T, ScalarExpr])(using CanBeEqualed[Result, T]): Expr[Boolean, ScalarExpr] = Expr.Ne[Shape, ScalarExpr](this, other) @targetName("nullSafeNeqNonScalar") - def !== (other: Expr[Result, NonScalarExpr]): Expr[Boolean, Shape] = Expr.NullSafeNe[Shape, NonScalarExpr](this, other) + def !== [T](other: Expr[T, NonScalarExpr])(using CanBeEqualed[Result, T]): Expr[Boolean, Shape] = Expr.NullSafeNe[Shape, NonScalarExpr](this, other) @targetName("nullSafeNeqScalar") - def !== (other: Expr[Result, ScalarExpr]): Expr[Boolean, ScalarExpr] = Expr.NullSafeNe[Shape, ScalarExpr](this, other) - @targetName("nullSafeNeqNonScalarUniveral") - def !== [T](other: Expr[T, NonScalarExpr])(using ResultTag[T], tyql.DialectFeature.WeaklyTypedEquality): Expr[Boolean, Shape] = Expr.NullSafeNe[Shape, NonScalarExpr](this, other) - @targetName("nullSafeNeqScalarUniveral") - def !== [T](other: Expr[T, ScalarExpr])(using ResultTag[T], tyql.DialectFeature.WeaklyTypedEquality): Expr[Boolean, ScalarExpr] = Expr.NullSafeNe[Shape, ScalarExpr](this, other) + def !== [T](other: Expr[T, ScalarExpr])(using CanBeEqualed[Result, T]): Expr[Boolean, ScalarExpr] = Expr.NullSafeNe[Shape, ScalarExpr](this, other) def isNull[S <: ExprShape]: Expr[Boolean, Shape] = Expr.IsNull(this) diff --git a/src/test/scala/test/integration/cases.scala b/src/test/scala/test/integration/cases.scala index 236aea3..5e35996 100644 --- a/src/test/scala/test/integration/cases.scala +++ b/src/test/scala/test/integration/cases.scala @@ -7,6 +7,8 @@ import tyql.{lit, True, False, Else} import java.sql.ResultSet import tyql.NonScalarExpr +import tyql.Dialect.ansi.given + class CaseTests extends FunSuite { def expectI(expected: Int)(rs: ResultSet) = assertEquals(rs.getInt(1), expected) def expectB(expected: Boolean)(rs: ResultSet) = assertEquals(rs.getBoolean(1), expected) diff --git a/src/test/scala/test/integration/null-safe equality.scala b/src/test/scala/test/integration/null-safe equality.scala index 7841fe9..2fa80c4 100644 --- a/src/test/scala/test/integration/null-safe equality.scala +++ b/src/test/scala/test/integration/null-safe equality.scala @@ -6,6 +6,8 @@ import tyql.* import test.{checkExpr, checkExprDialect, withDBNoImplicits, withDB} import scala.language.implicitConversions +import tyql.Dialect.ansi.given + class NullSafeEqualityTest extends FunSuite { def expectN(expected: String | scala.Null)(rs: ResultSet) = assertEquals(rs.getString(1), expected) def expectB(expected: Boolean)(rs: ResultSet) = assertEquals(rs.getBoolean(1), expected) @@ -51,7 +53,6 @@ class NullSafeEqualityTest extends FunSuite { t.filter(p => p.kiki == 10) // t.filter(p => p.kiki !== "a") // t.filter(p => p.kiki != "a") - // t.filter(p => p.kiki == 1.0) // (!) currently this is also unsupported because Int != Double } test("this still works with filters (mysql)") { @@ -63,4 +64,22 @@ class NullSafeEqualityTest extends FunSuite { t.filter(p => p.kiki !== "a") t.filter(p => p.kiki != "a") } + + test("postgres/duckdb/h2 despite not having universal equality can still compare different numeric types") { + { + import Dialect.postgresql.given + checkExprDialect[Boolean](lit(10) === 10.0, expectB(true))(withDB.postgres) + checkExprDialect[Boolean](lit(10.0) === 10, expectB(true))(withDB.postgres) + } + { + import Dialect.duckdb.given + checkExprDialect[Boolean](lit(10) === 10.0, expectB(true))(withDB.duckdb) + checkExprDialect[Boolean](lit(10.0) === 10, expectB(true))(withDB.duckdb) + } + { + import Dialect.h2.given + checkExprDialect[Boolean](lit(10) === 10.0, expectB(true))(withDB.h2) + checkExprDialect[Boolean](lit(10.0) === 10, expectB(true))(withDB.h2) + } + } } diff --git a/src/test/scala/test/integration/precedence.scala b/src/test/scala/test/integration/precedence.scala index edbb340..e115dd6 100644 --- a/src/test/scala/test/integration/precedence.scala +++ b/src/test/scala/test/integration/precedence.scala @@ -5,6 +5,8 @@ import test.{withDB, checkExprDialect} import java.sql.ResultSet import tyql.Expr +import tyql.Dialect.ansi.given + class PrecedenceTests extends FunSuite { def expectB(expected: Boolean)(rs: ResultSet) = assertEquals(rs.getBoolean(1), expected) def expectI(expected: Int)(rs: ResultSet) = assertEquals(rs.getInt(1), expected) diff --git a/src/test/scala/test/query/AggregationTests.scala b/src/test/scala/test/query/AggregationTests.scala index e9ab3e2..afe4f5e 100644 --- a/src/test/scala/test/query/AggregationTests.scala +++ b/src/test/scala/test/query/AggregationTests.scala @@ -8,6 +8,8 @@ import NamedTuple.* import scala.language.implicitConversions import Expr.{sum, avg, max} +import tyql.Dialect.ansi.given + // Expression-based aggregation: class AggregateAggregationExprTest extends SQLStringAggregationTest[AllCommerceDBs, Double] { diff --git a/src/test/scala/test/query/FailTests.scala b/src/test/scala/test/query/FailTests.scala index 60e5661..d39c92f 100644 --- a/src/test/scala/test/query/FailTests.scala +++ b/src/test/scala/test/query/FailTests.scala @@ -5,6 +5,8 @@ import tyql.* import test.query.{commerceDBs, AllCommerceDBs} import language.experimental.namedTuples +import Dialect.ansi.given + class MissingAttributeCompileErrorTest extends munit.FunSuite { def testDescription: String = "named tuples require attributes to exist" def expectedError: String = "doesNotExist is not a member of" diff --git a/src/test/scala/test/query/FlowTests.scala b/src/test/scala/test/query/FlowTests.scala index cec3311..e819740 100644 --- a/src/test/scala/test/query/FlowTests.scala +++ b/src/test/scala/test/query/FlowTests.scala @@ -9,6 +9,8 @@ import scala.language.implicitConversions import java.time.LocalDate import tyql.Expr.{sum, max} +import tyql.Dialect.ansi.given + class FlowForTest1 extends SQLStringQueryTest[AllCommerceDBs, (bName: String, bId: Int)] { def testDescription = "Flow: project tuple, 1 nest, for comprehension" def query() = diff --git a/src/test/scala/test/query/GroupByTests.scala b/src/test/scala/test/query/GroupByTests.scala index c95998b..df0609c 100644 --- a/src/test/scala/test/query/GroupByTests.scala +++ b/src/test/scala/test/query/GroupByTests.scala @@ -9,6 +9,8 @@ import NamedTuple.* import scala.language.implicitConversions import tyql.Expr.{avg, min, sum} +import tyql.Dialect.ansi.given + class GroupByTest extends SQLStringQueryTest[AllCommerceDBs, (total: Double)] { def testDescription = "GroupBy: simple" diff --git a/src/test/scala/test/query/JoinTests.scala b/src/test/scala/test/query/JoinTests.scala index 3449edb..419e790 100644 --- a/src/test/scala/test/query/JoinTests.scala +++ b/src/test/scala/test/query/JoinTests.scala @@ -8,6 +8,7 @@ import language.experimental.namedTuples import NamedTuple.* // import scala.language.implicitConversions +import tyql.Dialect.ansi.given import java.time.LocalDate diff --git a/src/test/scala/test/query/RecursiveBenchmarkTests.scala b/src/test/scala/test/query/RecursiveBenchmarkTests.scala index bc23b8c..27c076a 100644 --- a/src/test/scala/test/query/RecursiveBenchmarkTests.scala +++ b/src/test/scala/test/query/RecursiveBenchmarkTests.scala @@ -11,6 +11,8 @@ import NamedTuple.* type WeightedEdge = (src: Int, dst: Int, cost: Int) type WeightedGraphDB = (edge: WeightedEdge, base: (dst: Int, cost: Int)) +import tyql.Dialect.ansi.given + given WeightedGraphDBs: TestDatabase[WeightedGraphDB] with override def tables = ( edge = Table[WeightedEdge]("edge"), diff --git a/src/test/scala/test/query/RecursiveConstraintTests.scala b/src/test/scala/test/query/RecursiveConstraintTests.scala index 3415ec6..862ced7 100644 --- a/src/test/scala/test/query/RecursiveConstraintTests.scala +++ b/src/test/scala/test/query/RecursiveConstraintTests.scala @@ -13,6 +13,8 @@ type Edge = (x: Int, y: Int) type EdgeOther = (z: Int, q: Int) type TCDB = (edges: Edge, edges2: Edge, otherEdges: EdgeOther, emptyEdges: Edge) +import tyql.Dialect.ansi.given + given TCDBs: TestDatabase[TCDB] with override def tables = ( edges = Table[Edge]("edges"), diff --git a/src/test/scala/test/query/RecursiveTests.scala b/src/test/scala/test/query/RecursiveTests.scala index df0d6bd..b5bc8c2 100644 --- a/src/test/scala/test/query/RecursiveTests.scala +++ b/src/test/scala/test/query/RecursiveTests.scala @@ -12,6 +12,8 @@ type Edge = (x: Int, y: Int) type Edge2 = (z: Int, q: Int) type TCDB = (edges: Edge, otherEdges: Edge2, emptyEdges: Edge) +import tyql.Dialect.ansi.given + given TCDBs: TestDatabase[TCDB] with override def tables = ( edges = Table[Edge]("edges"), diff --git a/src/test/scala/test/query/ScopeTests.scala b/src/test/scala/test/query/ScopeTests.scala index be6d93a..b89bd2b 100644 --- a/src/test/scala/test/query/ScopeTests.scala +++ b/src/test/scala/test/query/ScopeTests.scala @@ -9,6 +9,8 @@ import language.experimental.namedTuples import NamedTuple.* // import scala.language.implicitConversions +import tyql.Dialect.ansi.given + class ScopeTest extends SQLStringQueryTest[AllCommerceDBs, Int] { def testDescription = "No subquery, filter/map one relation" diff --git a/src/test/scala/test/query/SelectTests.scala b/src/test/scala/test/query/SelectTests.scala index f7a77ac..059d716 100644 --- a/src/test/scala/test/query/SelectTests.scala +++ b/src/test/scala/test/query/SelectTests.scala @@ -13,6 +13,8 @@ type AddressT = (city: CityT, street: String, number: Int) type AllLocDBs = (cities: CityT, addresses: AddressT, cities2: CityT) type CityDB = (cities: CityT) +import tyql.Dialect.ansi.given + given cityDB: TestDatabase[CityDB] with override def tables = ( cities = Table[CityT]("cities") diff --git a/src/test/scala/test/query/SubqueryTests.scala b/src/test/scala/test/query/SubqueryTests.scala index 2eb8a1c..e608df0 100644 --- a/src/test/scala/test/query/SubqueryTests.scala +++ b/src/test/scala/test/query/SubqueryTests.scala @@ -11,6 +11,8 @@ import NamedTuple.* import java.time.LocalDate +import tyql.Dialect.ansi.given + class SortTakeJoinSubqueryTest extends SQLStringQueryTest[AllCommerceDBs, Double] { def testDescription = "Subquery: sortTakeJoin" def query() = From 4812a45897cca752e956c4029545d3f12e944ffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 26 Nov 2024 21:53:09 +0100 Subject: [PATCH 093/106] simplify numeric comparisons --- src/main/scala/tyql/TreePrettyPrinter.scala | 2 -- src/main/scala/tyql/expr/Expr.scala | 33 +++++-------------- src/main/scala/tyql/ir/QueryIRTree.scala | 10 +++--- src/test/scala/test/query/POCTests.scala | 2 ++ .../test/query/RecursiveBenchmarkTests.scala | 1 + src/test/scala/test/query/ScopeTests.scala | 1 + src/test/scala/test/query/SubqueryTests.scala | 1 + 7 files changed, 18 insertions(+), 32 deletions(-) diff --git a/src/main/scala/tyql/TreePrettyPrinter.scala b/src/main/scala/tyql/TreePrettyPrinter.scala index cce1dca..46801ea 100644 --- a/src/main/scala/tyql/TreePrettyPrinter.scala +++ b/src/main/scala/tyql/TreePrettyPrinter.scala @@ -60,8 +60,6 @@ object TreePrettyPrinter { s"${indent(depth)}NonEmpty(\n${list.prettyPrint(depth + 1)}\n${indent(depth)})" case IsEmpty(list) => s"${indent(depth)}IsEmpty(\n${list.prettyPrint(depth + 1)}\n${indent(depth)})" - case GtDouble(x, y) => s"${indent(depth)}GtDouble(\n${x.prettyPrint(depth + 1)},\n${y.prettyPrint(depth + 1)}\n${indent(depth)})" - case LtDouble(x, y) => s"${indent(depth)}LtDouble(\n${x.prettyPrint(depth + 1)},\n${y.prettyPrint(depth + 1)}\n${indent(depth)})" case And(x, y) => s"${indent(depth)}And(\n${x.prettyPrint(depth + 1)},\n${y.prettyPrint(depth + 1)}\n${indent(depth)})" case Or(x, y) => s"${indent(depth)}Or(\n${x.prettyPrint(depth + 1)},\n${y.prettyPrint(depth + 1)}\n${indent(depth)})" case Not(x) => s"${indent(depth)}Not(${x.prettyPrint(depth + 1)})" diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index 06fc116..ab4cd26 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -69,18 +69,6 @@ trait Expr[Result, Shape <: ExprShape](using val tag: ResultTag[Result]) extends object Expr: /** Sample extension methods for individual types */ extension [S1 <: ExprShape](x: Expr[Int, S1]) - // def >[S2 <: ExprShape](y: Expr[Int, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = Gt(x, y) - def >(y: Expr[Int, ScalarExpr]): Expr[Boolean, CalculatedShape[S1, ScalarExpr]] = Gt(x, y) - @targetName("gtIntNonscalar") - def >(y: Expr[Int, NonScalarExpr]): Expr[Boolean, CalculatedShape[S1, NonScalarExpr]] = Gt(x, y) - def >(y: Int): Expr[Boolean, S1] = Gt[S1, NonScalarExpr](x, IntLit(y)) - // def <[S2 <: ExprShape] (y: Expr[Int, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = Lt(x, y) - def <(y: Expr[Int, ScalarExpr]): Expr[Boolean, CalculatedShape[S1, ScalarExpr]] = Lt(x, y) - @targetName("ltIntNonscalar") - def <(y: Expr[Int, NonScalarExpr]): Expr[Boolean, CalculatedShape[S1, NonScalarExpr]] = Lt(x, y) - def <(y: Int): Expr[Boolean, S1] = Lt[S1, NonScalarExpr](x, IntLit(y)) - def <=[S2 <: ExprShape] (y: Expr[Int, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = Lte(x, y) - def >=[S2 <: ExprShape] (y: Expr[Int, S2]): Expr[Boolean, CalculatedShape[S1, S2]] = Gte(x, y) @targetName("addIntScalar") def +(y: Expr[Int, ScalarExpr]): Expr[Int, CalculatedShape[S1, ScalarExpr]] = Plus(x, y) @targetName("addIntNonScalar") @@ -95,12 +83,6 @@ object Expr: // TODO: write for numerical extension [S1 <: ExprShape](x: Expr[Double, S1]) - @targetName("gtDoubleScalar") - def >(y: Expr[Double, ScalarExpr]): Expr[Boolean, CalculatedShape[S1, ScalarExpr]] = GtDouble(x, y) - @targetName("gtDoubleNonScalar") - def >(y: Expr[Double, NonScalarExpr]): Expr[Boolean, CalculatedShape[S1, NonScalarExpr]] = GtDouble(x, y) - def >(y: Double): Expr[Boolean, S1] = GtDouble[S1, NonScalarExpr](x, DoubleLit(y)) - def <(y: Double): Expr[Boolean, S1] = LtDouble[S1, NonScalarExpr](x, DoubleLit(y)) @targetName("addDouble") def +[S2 <: ExprShape](y: Expr[Double, S2]): Expr[Double, CalculatedShape[S1, S2]] = Plus(x, y) @targetName("multipleDouble") @@ -108,6 +90,11 @@ object Expr: def *(y: Double): Expr[Double, S1] = Times[S1, NonScalarExpr, Double](x, DoubleLit(y)) extension [T: Numeric, S1 <: ExprShape](x: Expr[T, S1])(using ResultTag[T]) + def <[T2: Numeric, S2 <: ExprShape](y: Expr[T2, S2])(using ResultTag[T2]): Expr[Boolean, CalculatedShape[S1, S2]] = Lt(x, y) + def <=[T2: Numeric, S2 <: ExprShape](y: Expr[T2, S2])(using ResultTag[T2]): Expr[Boolean, CalculatedShape[S1, S2]] = Lte(x, y) + def >[T2: Numeric, S2 <: ExprShape](y: Expr[T2, S2])(using ResultTag[T2]): Expr[Boolean, CalculatedShape[S1, S2]] = Gt(x, y) + def >=[T2: Numeric, S2 <: ExprShape](y: Expr[T2, S2])(using ResultTag[T2]): Expr[Boolean, CalculatedShape[S1, S2]] = Gte(x, y) + def abs: Expr[T, S1] = Abs(x) def sqrt: Expr[Double, S1] = Sqrt(x) def round: Expr[Int, S1] = Round(x) @@ -227,12 +214,10 @@ object Expr: // a name in the domain model instead. // Some sample constructors for Exprs - case class Lt[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Int, S1], $y: Expr[Int, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] - case class Lte[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Int, S1], $y: Expr[Int, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] - case class Gte[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Int, S1], $y: Expr[Int, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] - case class Gt[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Int, S1], $y: Expr[Int, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] - case class GtDouble[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Double, S1], $y: Expr[Double, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] - case class LtDouble[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Double, S1], $y: Expr[Double, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] + case class Lt[T1: Numeric, T2: Numeric, S1 <: ExprShape, S2 <: ExprShape]($x: Expr[T1, S1], $y: Expr[T2, S2])(using ResultTag[T1], ResultTag[T2]) extends Expr[Boolean, CalculatedShape[S1, S2]] + case class Lte[T1: Numeric, T2: Numeric,S1 <: ExprShape, S2 <: ExprShape]($x: Expr[T1, S1], $y: Expr[T2, S2])(using ResultTag[T1], ResultTag[T2]) extends Expr[Boolean, CalculatedShape[S1, S2]] + case class Gt[T1: Numeric, T2: Numeric,S1 <: ExprShape, S2 <: ExprShape]($x: Expr[T1, S1], $y: Expr[T2, S2])(using ResultTag[T1], ResultTag[T2]) extends Expr[Boolean, CalculatedShape[S1, S2]] + case class Gte[T1: Numeric, T2: Numeric,S1 <: ExprShape, S2 <: ExprShape]($x: Expr[T1, S1], $y: Expr[T2, S2])(using ResultTag[T1], ResultTag[T2]) extends Expr[Boolean, CalculatedShape[S1, S2]] case class FunctionCall0[R](name: String)(using ResultTag[R]) extends Expr[R, NonScalarExpr] // XXX TODO NonScalarExpr? case class FunctionCall1[A1, R, S1 <: ExprShape](name: String, $a1: Expr[A1, S1])(using ResultTag[R]) extends Expr[R, S1] diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index fdf43d5..9fbe824 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -339,12 +339,10 @@ object QueryIRTree: QueryIRVar(sub, name, ref) // TODO: singleton? case s: Expr.Select[?] => SelectExpr(s.$name, generateExpr(s.$x, symbols), s) case p: Expr.Project[?] => generateProjection(p, symbols) - case g: Expr.Gt[?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l > $r", Precedence.Comparison, g) - case g: Expr.Lt[?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l < $r", Precedence.Comparison, g) - case g: Expr.Lte[?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l <= $r", Precedence.Comparison, g) - case g: Expr.Gte[?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l <= $r", Precedence.Comparison, g) - case g: Expr.GtDouble[?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l > $r", Precedence.Comparison, g) - case g: Expr.LtDouble[?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l < $r", Precedence.Comparison, g) + case g: Expr.Gt[?, ?, ?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l > $r", Precedence.Comparison, g) + case g: Expr.Lt[?, ?, ?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l < $r", Precedence.Comparison, g) + case g: Expr.Lte[?, ?, ?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l <= $r", Precedence.Comparison, g) + case g: Expr.Gte[?, ?, ?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l <= $r", Precedence.Comparison, g) case a: Expr.And[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l AND $r", Precedence.And, a) case a: Expr.Or[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l OR $r", Precedence.Or, a) case Expr.Not(inner: Expr.IsNull[?, ?]) => UnaryExprOp(generateExpr(inner.$x, symbols), o => s"$o IS NOT NULL", ast) diff --git a/src/test/scala/test/query/POCTests.scala b/src/test/scala/test/query/POCTests.scala index 6597edf..af92756 100644 --- a/src/test/scala/test/query/POCTests.scala +++ b/src/test/scala/test/query/POCTests.scala @@ -8,6 +8,8 @@ import tyql.* import language.experimental.namedTuples import NamedTuple.* +import scala.language.implicitConversions + class AbstractOverValuesHostTest extends SQLStringQueryTest[AllCommerceDBs, Product] { def testDescription: String = "Support for abstracting over values in the host language" def query() = diff --git a/src/test/scala/test/query/RecursiveBenchmarkTests.scala b/src/test/scala/test/query/RecursiveBenchmarkTests.scala index 27c076a..9575c6d 100644 --- a/src/test/scala/test/query/RecursiveBenchmarkTests.scala +++ b/src/test/scala/test/query/RecursiveBenchmarkTests.scala @@ -12,6 +12,7 @@ type WeightedEdge = (src: Int, dst: Int, cost: Int) type WeightedGraphDB = (edge: WeightedEdge, base: (dst: Int, cost: Int)) import tyql.Dialect.ansi.given +import scala.language.implicitConversions given WeightedGraphDBs: TestDatabase[WeightedGraphDB] with override def tables = ( diff --git a/src/test/scala/test/query/ScopeTests.scala b/src/test/scala/test/query/ScopeTests.scala index b89bd2b..88a5cbf 100644 --- a/src/test/scala/test/query/ScopeTests.scala +++ b/src/test/scala/test/query/ScopeTests.scala @@ -10,6 +10,7 @@ import NamedTuple.* // import scala.language.implicitConversions import tyql.Dialect.ansi.given +import scala.language.implicitConversions class ScopeTest extends SQLStringQueryTest[AllCommerceDBs, Int] { def testDescription = "No subquery, filter/map one relation" diff --git a/src/test/scala/test/query/SubqueryTests.scala b/src/test/scala/test/query/SubqueryTests.scala index e608df0..0a469de 100644 --- a/src/test/scala/test/query/SubqueryTests.scala +++ b/src/test/scala/test/query/SubqueryTests.scala @@ -12,6 +12,7 @@ import NamedTuple.* import java.time.LocalDate import tyql.Dialect.ansi.given +import scala.language.implicitConversions class SortTakeJoinSubqueryTest extends SQLStringQueryTest[AllCommerceDBs, Double] { def testDescription = "Subquery: sortTakeJoin" From a760ea8231d3e85823538dafa3db537772d63ae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Tue, 26 Nov 2024 22:17:17 +0100 Subject: [PATCH 094/106] simplify arithmetic operations --- src/main/scala/tyql/TreePrettyPrinter.scala | 2 +- src/main/scala/tyql/expr/Expr.scala | 24 +++++---------------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/src/main/scala/tyql/TreePrettyPrinter.scala b/src/main/scala/tyql/TreePrettyPrinter.scala index 46801ea..793989d 100644 --- a/src/main/scala/tyql/TreePrettyPrinter.scala +++ b/src/main/scala/tyql/TreePrettyPrinter.scala @@ -11,7 +11,7 @@ object TreePrettyPrinter { import Expr.* import AggregationExpr.* - private def indent(level: Int): String = " " * level + private def indent(level: Int): String = scala.collection.StringOps(" ") * level // TODO this broke for some reason private def indentWithKey(level: Int, key: String, value: String): String = s"${indent(level)}$key=${value.stripLeading()}" private def indentListWithKey(level: Int, key: String, values: Seq[String]): String = if (values.isEmpty) diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index ab4cd26..2dae4c4 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -69,32 +69,18 @@ trait Expr[Result, Shape <: ExprShape](using val tag: ResultTag[Result]) extends object Expr: /** Sample extension methods for individual types */ extension [S1 <: ExprShape](x: Expr[Int, S1]) - @targetName("addIntScalar") - def +(y: Expr[Int, ScalarExpr]): Expr[Int, CalculatedShape[S1, ScalarExpr]] = Plus(x, y) - @targetName("addIntNonScalar") - def +(y: Expr[Int, NonScalarExpr]): Expr[Int, CalculatedShape[S1, NonScalarExpr]] = Plus(x, y) - def +(y: Int): Expr[Int, S1] = Plus[S1, NonScalarExpr, Int](x, IntLit(y)) - @targetName("multiplyIntScalar") - def *(y: Expr[Int, ScalarExpr]): Expr[Int, CalculatedShape[S1, ScalarExpr]] = Times(x, y) - @targetName("multiplyIntNonScalar") - def *(y: Expr[Int, NonScalarExpr]): Expr[Int, CalculatedShape[S1, NonScalarExpr]] = Times(x, y) - def *(y: Int): Expr[Int, S1] = Times(x, IntLit(y)) def %[S2 <: ExprShape](y: Expr[Int, S2]): Expr[Int, CalculatedShape[S1, S2]] = Modulo(x, y) - // TODO: write for numerical - extension [S1 <: ExprShape](x: Expr[Double, S1]) - @targetName("addDouble") - def +[S2 <: ExprShape](y: Expr[Double, S2]): Expr[Double, CalculatedShape[S1, S2]] = Plus(x, y) - @targetName("multipleDouble") - def *[S2 <: ExprShape](y: Expr[Double, S2]): Expr[Double, CalculatedShape[S1, S2]] = Times(x, y) - def *(y: Double): Expr[Double, S1] = Times[S1, NonScalarExpr, Double](x, DoubleLit(y)) - extension [T: Numeric, S1 <: ExprShape](x: Expr[T, S1])(using ResultTag[T]) def <[T2: Numeric, S2 <: ExprShape](y: Expr[T2, S2])(using ResultTag[T2]): Expr[Boolean, CalculatedShape[S1, S2]] = Lt(x, y) def <=[T2: Numeric, S2 <: ExprShape](y: Expr[T2, S2])(using ResultTag[T2]): Expr[Boolean, CalculatedShape[S1, S2]] = Lte(x, y) def >[T2: Numeric, S2 <: ExprShape](y: Expr[T2, S2])(using ResultTag[T2]): Expr[Boolean, CalculatedShape[S1, S2]] = Gt(x, y) def >=[T2: Numeric, S2 <: ExprShape](y: Expr[T2, S2])(using ResultTag[T2]): Expr[Boolean, CalculatedShape[S1, S2]] = Gte(x, y) + def +[S2 <: ExprShape](y: Expr[T, S2]): Expr[T, CalculatedShape[S1, S2]] = Plus(x, y) + def -[S2 <: ExprShape](y: Expr[T, S2]): Expr[T, CalculatedShape[S1, S2]] = Minus(x, y) + def *[S2 <: ExprShape](y: Expr[T, S2]): Expr[T, CalculatedShape[S1, S2]] = Times(x, y) + def abs: Expr[T, S1] = Abs(x) def sqrt: Expr[Double, S1] = Sqrt(x) def round: Expr[Int, S1] = Round(x) @@ -107,7 +93,6 @@ object Expr: def log(base: Expr[T, S1]): Expr[Double, CalculatedShape[S1, S1]] = Log(base, x) def log10: Expr[Double, S1] = Log(IntLit(10), x).asInstanceOf[Expr[Double, S1]] // TODO cast? def log2: Expr[Double, S1] = Log(IntLit(2), x).asInstanceOf[Expr[Double, S1]] // TODO cast? - def -[S2 <: ExprShape](y: Expr[T, S2]): Expr[T, CalculatedShape[S1, S2]] = Minus(x, y) def exp[T: Numeric, S <: ExprShape](x: Expr[T, S])(using ResultTag[T]): Expr[Double, S] = Exp(x) def sin[T: Numeric, S <: ExprShape](x: Expr[T, S])(using ResultTag[T]): Expr[Double, S] = Sin(x) @@ -226,6 +211,7 @@ object Expr: case class Plus[S1 <: ExprShape, S2 <: ExprShape, T: Numeric]($x: Expr[T, S1], $y: Expr[T, S2])(using ResultTag[T]) extends Expr[T, CalculatedShape[S1, S2]] case class Minus[S1 <: ExprShape, S2 <: ExprShape, T: Numeric]($x: Expr[T, S1], $y: Expr[T, S2])(using ResultTag[T]) extends Expr[T, CalculatedShape[S1, S2]] case class Times[S1 <: ExprShape, S2 <: ExprShape, T: Numeric]($x: Expr[T, S1], $y: Expr[T, S2])(using ResultTag[T]) extends Expr[T, CalculatedShape[S1, S2]] + case class And[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Boolean, S1], $y: Expr[Boolean, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] case class Or[S1 <: ExprShape, S2 <: ExprShape]($x: Expr[Boolean, S1], $y: Expr[Boolean, S2]) extends Expr[Boolean, CalculatedShape[S1, S2]] case class Not[S1 <: ExprShape]($x: Expr[Boolean, S1]) extends Expr[Boolean, S1] From a479d9c921b4ba6f7f7975b09e0bd53ff6b1b5c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sun, 1 Dec 2024 14:54:32 +0100 Subject: [PATCH 095/106] driver converts fetched values to native types again --- src/main/scala/tyql/ResultTag.scala | 4 +- src/main/scala/tyql/driver.scala | 94 +++++++++++++++++++++ src/main/scala/tyql/main.scala | 85 +------------------ src/main/scala/tyql/query/DatabaseAST.scala | 1 + 4 files changed, 99 insertions(+), 85 deletions(-) create mode 100644 src/main/scala/tyql/driver.scala diff --git a/src/main/scala/tyql/ResultTag.scala b/src/main/scala/tyql/ResultTag.scala index 6171202..2ca59f3 100644 --- a/src/main/scala/tyql/ResultTag.scala +++ b/src/main/scala/tyql/ResultTag.scala @@ -15,7 +15,7 @@ enum ResultTag[T]: // names is a var to special case when we want to treat a named tuple like a regular tuple without going through type conversion case NamedTupleTag[N <: Tuple, V <: Tuple](var names: List[String], types: List[ResultTag[?]]) extends ResultTag[NamedTuple[N, V]] // case TupleTag[T <: Tuple](types: List[ResultTag[?]]) extends ResultTag[Tuple] - case ProductTag[T](productName: String, fields: ResultTag[NamedTuple.From[T]]) extends ResultTag[T] + case ProductTag[T](productName: String, fields: ResultTag[NamedTuple.From[T]], m: Mirror.ProductOf[T]) extends ResultTag[T] case ListTag[T](elementType: ResultTag[T]) extends ResultTag[List[T]] case AnyTag extends ResultTag[Any] // TODO: Add more types, specialize for DB backend @@ -39,6 +39,6 @@ object ResultTag: // Alternatively if we don't care about the case class name we could use only `fields`. inline given [T](using m: Mirror.ProductOf[T], fields: ResultTag[NamedTuple.From[T]]): ResultTag[T] = val productName = constValue[m.MirroredLabel] - ProductTag(productName, fields) + ProductTag(productName, fields, m) inline given [T](using elementType: ResultTag[T]): ResultTag[List[T]] = ResultTag.ListTag(elementType) diff --git a/src/main/scala/tyql/driver.scala b/src/main/scala/tyql/driver.scala new file mode 100644 index 0000000..f5b4134 --- /dev/null +++ b/src/main/scala/tyql/driver.scala @@ -0,0 +1,94 @@ +package tyql + +import java.sql.{Connection, ResultSet, DriverManager} +import scala.NamedTuple.NamedTuple +import pprint.pprintln +import scala.deriving.Mirror +import scala.Tuple + +class DB(conn: Connection) { + def run[T](dbast: DatabaseAST[T])(using resultTag: ResultTag[T], + dialect: tyql.Dialect, + config: tyql.Config): List[T] = { + val sqlString = dbast.toQueryIR.toSQLString() + println("SQL STRING WAS " + sqlString) + val stmt = conn.createStatement() + val rs = stmt.executeQuery(sqlString) + val metadata = rs.getMetaData() + val columnCount = metadata.getColumnCount() + var results = List[T]() + while (rs.next()) { + val row = resultTag match + case ResultTag.IntTag => rs.getInt(1) + case ResultTag.DoubleTag => rs.getDouble(1) + case ResultTag.StringTag => rs.getString(1) + case ResultTag.BoolTag => rs.getBoolean(1) + case ResultTag.ProductTag(_, fields, m) => { + val kkk = fields.asInstanceOf[ResultTag.NamedTupleTag[?,?]] + val fieldValues = kkk.names.zip(kkk.types).map { (name, tag) => + val casedName = config.caseConvention.convert(name) + tag match + case ResultTag.IntTag => rs.getInt(casedName) + case ResultTag.DoubleTag => rs.getDouble(casedName) + case ResultTag.StringTag => rs.getString(casedName) + case ResultTag.BoolTag => rs.getBoolean(casedName) + case _ => assert(false, "Unsupported type") + } + m.fromProduct(Tuple.fromArray(fieldValues.toArray)) + } + case _ => assert(false, "Unsupported type") + results = row.asInstanceOf[T] :: results + } + rs.close() + stmt.close() + results.reverse + } + + def runDebug[T](dbast: DatabaseAST[T])(using resultTag: ResultTag[T], + dialect: tyql.Dialect, + config: tyql.Config): List[Map[String, Any]] = { + val sqlString = dbast.toQueryIR.toSQLString() + println("SQL STRING WAS " + sqlString) + val stmt = conn.createStatement() + val rs = stmt.executeQuery(sqlString) + val metadata = rs.getMetaData() + val columnCount = metadata.getColumnCount() + var results = List[Map[String, Any]]() + while (rs.next()) { + val row = (1 to columnCount).map { i => + metadata.getColumnName(i) -> rs.getObject(i) + }.toMap + results = row :: results + } + rs.close() + stmt.close() + results.reverse + } +} + + + +def driverMain(): Unit = { + import scala.language.implicitConversions + val conn = DriverManager.getConnection("jdbc:mariadb://localhost:3308/testdb", "testuser", "testpass") + val db = DB(conn) + given tyql.Config = new tyql.Config(tyql.CaseConvention.Underscores) {} + case class Flowers(name: String, flowerSize: Int, cost: Double, likes: Int) + val t = new tyql.Table[Flowers]("flowers") + + println("------------1------------") + val zzz = db.run(t.filter(t => t.flowerSize >= 10)) + println("received:") + pprintln(zzz) + println("likes are " + zzz.head.likes.toString()) + + println("------------2------------") + val zzz2 = db.run(t.map(_.name)).map(_ + "!!!") // TODO if this map is inside then we generate incorrect SQL due to incorrect subquery field naming :( + println("received:") + pprintln(zzz2) + + println("------------3------------") + val zzz3 = db.run(t.max(_.flowerSize)) + println("received:") + pprintln(zzz3) +} diff --git a/src/main/scala/tyql/main.scala b/src/main/scala/tyql/main.scala index 7242be3..b367832 100644 --- a/src/main/scala/tyql/main.scala +++ b/src/main/scala/tyql/main.scala @@ -3,88 +3,7 @@ package tyql import language.experimental.namedTuples import scala.language.implicitConversions import NamedTuple.{AnyNamedTuple, NamedTuple} -import scala.io.Source -import java.io.File -import buildinfo.BuildInfo -import scalasql.Table as ScalaSQLTable -import scalasql.PostgresDialect._ -//import tyql.fix.FixedPointQuery.fix -// -//type Edge = (x: Int, y: Int) -//type GraphDB = (edge: Edge) -// -//val testDB = (tables = ( -// edge = Seq[Edge]( -// (x = 0, y = 1), -// (x = 1, y = 2), -// (x = 2, y = 3) -// ) -//)) -// - -def readDDLFile(filePath: String): Seq[String] = { - val src = Source.fromFile(new File(filePath)) - val fileContents = src.getLines().mkString("\n") - val result = fileContents.split(";").map(_.trim).filter(_.nonEmpty).toSeq - src.close() - result -} - -case class EdgeSS[T[_]](x: T[Int], y: T[Int]) - -case class ResultEdgeSS[T[_]](startNode: T[Int], endNode: T[Int], path: T[Seq[Int]]) - -object Edge extends ScalaSQLTable[EdgeSS] +import tyql.* @main def main() = - import java.sql.{Connection, DriverManager, ResultSet} - - Class.forName("org.duckdb.DuckDBDriver") - - val connection: Connection = DriverManager.getConnection("jdbc:duckdb:") - - - try { - val ddl = s"${BuildInfo.baseDirectory}/bench/data/tc/schema.ddl" - val ddlCmds = readDDLFile(ddl) - val statement = connection.createStatement() - - ddlCmds.foreach(ddl => - println(s"Executing DDL: $ddl") - statement.execute(ddl) - ) - - statement.execute(s"COPY tc_edge FROM '${BuildInfo.baseDirectory}/bench/data/tc/data/edge.csv'") - - val resultSet: ResultSet = statement.executeQuery("SELECT * FROM tc_edge") - - println("Query Results:") - while (resultSet.next()) { - val x = resultSet.getInt("x") - val y = resultSet.getInt("y") - println(s"x: $x, y: $y") - } - val dbClient = scalasql.DbClient.Connection(connection, new scalasql.Config { - override def tableNameMapper(v: String) = s"tc_${v.toLowerCase()}" - }) - val db = dbClient.getAutoCommitClientConnection - val query = Edge.select - println(s"ScalaSQL query=${db.renderSql(query)}") - val res = db.run(query) - println(s"ScalaSQL result=$res") - - - } finally { - connection.close() - } - -// val path = testDB.tables.edge -// val result = fix(path, Seq())(path => -// path.flatMap(p => -// testDB.tables.edge -// .filter(e => p.y == e.x) -// .map(e => (x = p.x, y = e.y)) -// ).distinct -// )//.filter(p => p.x > 1).map(p => p.x) -// -// println(s"fix=$result") + driverMain() diff --git a/src/main/scala/tyql/query/DatabaseAST.scala b/src/main/scala/tyql/query/DatabaseAST.scala index 4e2022a..2c12763 100644 --- a/src/main/scala/tyql/query/DatabaseAST.scala +++ b/src/main/scala/tyql/query/DatabaseAST.scala @@ -8,4 +8,5 @@ trait DatabaseAST[Result](using val qTag: ResultTag[Result]): def toSQLString(using d: Dialect)(using cnf: Config): String = toQueryIR.toSQLString() def toQueryIR(using d: Dialect): QueryIRNode = + println(qTag.toString) QueryIRTree.generateFullQuery(this, SymbolTable()) From 6efe02337c4400e09d6e24b6d1b344a41c114b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sun, 1 Dec 2024 15:20:56 +0100 Subject: [PATCH 096/106] remove that println --- src/main/scala/tyql/query/DatabaseAST.scala | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/scala/tyql/query/DatabaseAST.scala b/src/main/scala/tyql/query/DatabaseAST.scala index 2c12763..4e2022a 100644 --- a/src/main/scala/tyql/query/DatabaseAST.scala +++ b/src/main/scala/tyql/query/DatabaseAST.scala @@ -8,5 +8,4 @@ trait DatabaseAST[Result](using val qTag: ResultTag[Result]): def toSQLString(using d: Dialect)(using cnf: Config): String = toQueryIR.toSQLString() def toQueryIR(using d: Dialect): QueryIRNode = - println(qTag.toString) QueryIRTree.generateFullQuery(this, SymbolTable()) From 3b0180bb8a5d51e60503cd297257007566c956e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sun, 1 Dec 2024 15:22:07 +0100 Subject: [PATCH 097/106] Gte works now --- src/main/scala/tyql/ir/QueryIRTree.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index 9fbe824..6780031 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -342,7 +342,7 @@ object QueryIRTree: case g: Expr.Gt[?, ?, ?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l > $r", Precedence.Comparison, g) case g: Expr.Lt[?, ?, ?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l < $r", Precedence.Comparison, g) case g: Expr.Lte[?, ?, ?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l <= $r", Precedence.Comparison, g) - case g: Expr.Gte[?, ?, ?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l <= $r", Precedence.Comparison, g) + case g: Expr.Gte[?, ?, ?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l >= $r", Precedence.Comparison, g) case a: Expr.And[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l AND $r", Precedence.And, a) case a: Expr.Or[?, ?] => BinExprOp(generateExpr(a.$x, symbols), generateExpr(a.$y, symbols), (l, r) => s"$l OR $r", Precedence.Or, a) case Expr.Not(inner: Expr.IsNull[?, ?]) => UnaryExprOp(generateExpr(inner.$x, symbols), o => s"$o IS NOT NULL", ast) From 56da5d9e9aede08caea5464eb26d9d9414ceb2cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sun, 1 Dec 2024 15:48:59 +0100 Subject: [PATCH 098/106] a way to handle NULLS gracefully: Option --- src/main/scala/tyql/ResultTag.scala | 2 ++ src/main/scala/tyql/driver.scala | 28 +++++++++++++++++++++++----- src/main/scala/tyql/expr/Expr.scala | 10 ++++++++++ 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/main/scala/tyql/ResultTag.scala b/src/main/scala/tyql/ResultTag.scala index 2ca59f3..ec887e8 100644 --- a/src/main/scala/tyql/ResultTag.scala +++ b/src/main/scala/tyql/ResultTag.scala @@ -17,6 +17,7 @@ enum ResultTag[T]: // case TupleTag[T <: Tuple](types: List[ResultTag[?]]) extends ResultTag[Tuple] case ProductTag[T](productName: String, fields: ResultTag[NamedTuple.From[T]], m: Mirror.ProductOf[T]) extends ResultTag[T] case ListTag[T](elementType: ResultTag[T]) extends ResultTag[List[T]] + case OptionalTag[T](elementType: ResultTag[T]) extends ResultTag[Option[T]] case AnyTag extends ResultTag[Any] // TODO: Add more types, specialize for DB backend object ResultTag: @@ -26,6 +27,7 @@ object ResultTag: given ResultTag[Boolean] = ResultTag.BoolTag given ResultTag[Double] = ResultTag.DoubleTag given ResultTag[LocalDate] = ResultTag.LocalDateTag + given [T](using e: ResultTag[T]): ResultTag[Option[T]] = ResultTag.OptionalTag(e) // inline given [T <: Tuple]: ResultTag[Tuple] = // val tpes = summonAll[Tuple.Map[T, ResultTag]] // TupleTag(tpes.toList.asInstanceOf[List[ResultTag[?]]]) diff --git a/src/main/scala/tyql/driver.scala b/src/main/scala/tyql/driver.scala index f5b4134..c35be2d 100644 --- a/src/main/scala/tyql/driver.scala +++ b/src/main/scala/tyql/driver.scala @@ -23,6 +23,16 @@ class DB(conn: Connection) { case ResultTag.DoubleTag => rs.getDouble(1) case ResultTag.StringTag => rs.getString(1) case ResultTag.BoolTag => rs.getBoolean(1) + case ResultTag.OptionalTag(e) => { + val got = rs.getObject(1) + if got == null then None + else e match + case ResultTag.IntTag => Some(got.asInstanceOf[Int]) + case ResultTag.DoubleTag => Some(got.asInstanceOf[Double]) + case ResultTag.StringTag => Some(got.asInstanceOf[String]) + case ResultTag.BoolTag => Some(got.asInstanceOf[Boolean]) + case _ => assert(false, "Unsupported type") + } case ResultTag.ProductTag(_, fields, m) => { val kkk = fields.asInstanceOf[ResultTag.NamedTupleTag[?,?]] val fieldValues = kkk.names.zip(kkk.types).map { (name, tag) => @@ -32,6 +42,16 @@ class DB(conn: Connection) { case ResultTag.DoubleTag => rs.getDouble(casedName) case ResultTag.StringTag => rs.getString(casedName) case ResultTag.BoolTag => rs.getBoolean(casedName) + case ResultTag.OptionalTag(e) => { + val got = rs.getObject(casedName) + if got == null then None + else e match + case ResultTag.IntTag => Some(got.asInstanceOf[Int]) + case ResultTag.DoubleTag => Some(got.asInstanceOf[Double]) + case ResultTag.StringTag => Some(got.asInstanceOf[String]) + case ResultTag.BoolTag => Some(got.asInstanceOf[Boolean]) + case _ => assert(false, "Unsupported type") + } case _ => assert(false, "Unsupported type") } m.fromProduct(Tuple.fromArray(fieldValues.toArray)) @@ -66,24 +86,22 @@ class DB(conn: Connection) { } } - - def driverMain(): Unit = { import scala.language.implicitConversions val conn = DriverManager.getConnection("jdbc:mariadb://localhost:3308/testdb", "testuser", "testpass") val db = DB(conn) given tyql.Config = new tyql.Config(tyql.CaseConvention.Underscores) {} - case class Flowers(name: String, flowerSize: Int, cost: Double, likes: Int) + case class Flowers(name: Option[String], flowerSize: Int, cost: Option[Double], likes: Int) val t = new tyql.Table[Flowers]("flowers") println("------------1------------") - val zzz = db.run(t.filter(t => t.flowerSize >= 10)) + val zzz = db.run(t.filter(t => t.flowerSize.isNull || t.flowerSize >= 2)) println("received:") pprintln(zzz) println("likes are " + zzz.head.likes.toString()) println("------------2------------") - val zzz2 = db.run(t.map(_.name)).map(_ + "!!!") // TODO if this map is inside then we generate incorrect SQL due to incorrect subquery field naming :( + val zzz2 = db.run(t.map(_.name.getOrElse("UNKNOWN"))) println("received:") pprintln(zzz2) diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index 2dae4c4..8dcbd44 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -108,6 +108,16 @@ object Expr: def unary_! = Not(x) def ^(y: Expr[Boolean, S1]): Expr[Boolean, S1] = Xor(x, y) + extension [S1 <: ExprShape, T](x: Expr[Option[T], S1])(using ResultTag[T]) + def isEmpty: Expr[Boolean, S1] = Expr.IsNull(x) + def isDefined: Expr[Boolean, S1] = Not(Expr.IsNull(x)) + def get: Expr[T, S1] = x.asInstanceOf[Expr[T, S1]] // TODO should this error silently? + def getOrElse(default: Expr[T, S1]): Expr[T, S1] = coalesce(x.asInstanceOf[Expr[T, S1]], default) + def map[U: ResultTag, S2 <: ExprShape](f: Expr[T, S1] => Expr[U, S2]): Expr[Option[U], CalculatedShape[S1, S2]] = ??? + // TODO unclear how to implement map + // TODO unclear how to implement flatMap + // TODO somehow use options in aggregations + extension [S1 <: ExprShape](x: Expr[String, S1]) def toLowerCase: Expr[String, S1] = Expr.Lower(x) def toUpperCase: Expr[String, S1] = Expr.Upper(x) From 9b61ea7940ee19ca691fc76f3a6e97c870d35b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sun, 1 Dec 2024 16:08:51 +0100 Subject: [PATCH 099/106] driver: use int column indices --- src/main/scala/tyql/driver.scala | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/scala/tyql/driver.scala b/src/main/scala/tyql/driver.scala index c35be2d..7f85891 100644 --- a/src/main/scala/tyql/driver.scala +++ b/src/main/scala/tyql/driver.scala @@ -34,16 +34,16 @@ class DB(conn: Connection) { case _ => assert(false, "Unsupported type") } case ResultTag.ProductTag(_, fields, m) => { - val kkk = fields.asInstanceOf[ResultTag.NamedTupleTag[?,?]] - val fieldValues = kkk.names.zip(kkk.types).map { (name, tag) => - val casedName = config.caseConvention.convert(name) + val nt = fields.asInstanceOf[ResultTag.NamedTupleTag[?,?]] + val fieldValues = nt.names.zip(nt.types).zipWithIndex.map { case ((name, tag), idx) => + val col = idx + 1 // XXX if you want to use `name` here, you must case-convert it tag match - case ResultTag.IntTag => rs.getInt(casedName) - case ResultTag.DoubleTag => rs.getDouble(casedName) - case ResultTag.StringTag => rs.getString(casedName) - case ResultTag.BoolTag => rs.getBoolean(casedName) + case ResultTag.IntTag => rs.getInt(col) + case ResultTag.DoubleTag => rs.getDouble(col) + case ResultTag.StringTag => rs.getString(col) + case ResultTag.BoolTag => rs.getBoolean(col) case ResultTag.OptionalTag(e) => { - val got = rs.getObject(casedName) + val got = rs.getObject(col) if got == null then None else e match case ResultTag.IntTag => Some(got.asInstanceOf[Int]) From fc84a66015f4f25ad90bfcc47fa6514c787fd81e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sun, 1 Dec 2024 16:19:47 +0100 Subject: [PATCH 100/106] something towards Option.map, unclear if correct --- src/main/scala/tyql/expr/Expr.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index 8dcbd44..c8b8537 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -113,8 +113,7 @@ object Expr: def isDefined: Expr[Boolean, S1] = Not(Expr.IsNull(x)) def get: Expr[T, S1] = x.asInstanceOf[Expr[T, S1]] // TODO should this error silently? def getOrElse(default: Expr[T, S1]): Expr[T, S1] = coalesce(x.asInstanceOf[Expr[T, S1]], default) - def map[U: ResultTag, S2 <: ExprShape](f: Expr[T, S1] => Expr[U, S2]): Expr[Option[U], CalculatedShape[S1, S2]] = ??? - // TODO unclear how to implement map + def map[U: ResultTag, S2 <: ExprShape](f: Ref[T, NonScalarExpr] => Expr[U, NonScalarExpr]): Expr[Option[U], S1] = OptionMap(x, f) // TODO unclear how to implement flatMap // TODO somehow use options in aggregations @@ -327,6 +326,8 @@ object Expr: case class SearchedCase[T, SC <: ExprShape, SV <: ExprShape]($cases: List[(Expr[Boolean, SC], Expr[T, SV])], $else: Option[Expr[T, SV]])(using ResultTag[T]) extends Expr[T, SV] case class SimpleCase[TE, TR, SE <: ExprShape, SR <: ExprShape]($expr: Expr[TE, SE], $cases: List[(Expr[TE, SE], Expr[TR, SR])], $else: Option[Expr[TR, SR]])(using ResultTag[TE], ResultTag[TR]) extends Expr[TR, SR] + case class OptionMap[A, B, S <: ExprShape]($x: Expr[Option[A], S], $f: Ref[A, NonScalarExpr] => Expr[B, NonScalarExpr])(using ResultTag[A], ResultTag[B]) extends Expr[Option[B], S] + case class NullLit[A]()(using ResultTag[A]) extends Expr[A, NonScalarExpr] case class IsNull[A, S <: ExprShape]($x: Expr[A, S]) extends Expr[Boolean, S] case class Coalesce[A, S1 <: ExprShape]($x1: Expr[A, S1], $x2: Expr[A, S1], $xs: Seq[Expr[A, S1]])(using ResultTag[A]) extends Expr[A, S1] From 4b2e4e9680268044c99cde053d3001d51e3605f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sun, 1 Dec 2024 16:22:26 +0100 Subject: [PATCH 101/106] some printlns --- src/main/scala/tyql/driver.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/scala/tyql/driver.scala b/src/main/scala/tyql/driver.scala index 7f85891..7e86d50 100644 --- a/src/main/scala/tyql/driver.scala +++ b/src/main/scala/tyql/driver.scala @@ -11,7 +11,7 @@ class DB(conn: Connection) { dialect: tyql.Dialect, config: tyql.Config): List[T] = { val sqlString = dbast.toQueryIR.toSQLString() - println("SQL STRING WAS " + sqlString) + println("SQL << " + sqlString + " >>") val stmt = conn.createStatement() val rs = stmt.executeQuery(sqlString) val metadata = rs.getMetaData() @@ -68,7 +68,7 @@ class DB(conn: Connection) { dialect: tyql.Dialect, config: tyql.Config): List[Map[String, Any]] = { val sqlString = dbast.toQueryIR.toSQLString() - println("SQL STRING WAS " + sqlString) + println("SQL << " + sqlString + " >>") val stmt = conn.createStatement() val rs = stmt.executeQuery(sqlString) val metadata = rs.getMetaData() From 525bc81a56d21a9d8f5a4b762e2f0497b17c5b98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sun, 1 Dec 2024 17:01:40 +0100 Subject: [PATCH 102/106] tables do not need to be named when generated from a case class --- src/main/scala/tyql/driver.scala | 2 +- src/main/scala/tyql/query/Table.scala | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/scala/tyql/driver.scala b/src/main/scala/tyql/driver.scala index 7e86d50..9fc83f9 100644 --- a/src/main/scala/tyql/driver.scala +++ b/src/main/scala/tyql/driver.scala @@ -92,7 +92,7 @@ def driverMain(): Unit = { val db = DB(conn) given tyql.Config = new tyql.Config(tyql.CaseConvention.Underscores) {} case class Flowers(name: Option[String], flowerSize: Int, cost: Option[Double], likes: Int) - val t = new tyql.Table[Flowers]("flowers") + val t = tyql.Table[Flowers]() println("------------1------------") val zzz = db.run(t.filter(t => t.flowerSize.isNull || t.flowerSize >= 2)) diff --git a/src/main/scala/tyql/query/Table.scala b/src/main/scala/tyql/query/Table.scala index 110bb76..27876a5 100644 --- a/src/main/scala/tyql/query/Table.scala +++ b/src/main/scala/tyql/query/Table.scala @@ -1,6 +1,16 @@ package tyql -/** The type of query references to database tables, TODO: put driver stuff here? */ -case class Table[R]($name: String)(using ResultTag[R]) extends Query[R, BagResult] +import scala.deriving.Mirror -// case class Database(tables: ) // need seq of tables \ No newline at end of file +/** The type of query references to database tables */ +case class Table[R] private ($name: String)(using r: ResultTag[R]) extends Query[R, BagResult] + +object Table { + def apply[R]()(using r: ResultTag[R], m: Mirror.Of[R], config: tyql.Config): Table[R] = + new Table[R](config.caseConvention.convert(m.toString)) + def apply[R](name: String)(using r: ResultTag[R]): Table[R] = + new Table[R](name) + // TODO I dislike this () here. I would prefer something like Table[Products] and this would just summon the right thing without making any variables +} + +// case class Database(tables: ) // TODO, do we need this? From e03a4c277f106d941e2f4e2b8a357d6f251d57a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sun, 1 Dec 2024 17:05:12 +0100 Subject: [PATCH 103/106] a comment --- src/main/scala/tyql/ir/QueryIRNode.scala | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/scala/tyql/ir/QueryIRNode.scala b/src/main/scala/tyql/ir/QueryIRNode.scala index c7a221c..1b4f99c 100644 --- a/src/main/scala/tyql/ir/QueryIRNode.scala +++ b/src/main/scala/tyql/ir/QueryIRNode.scala @@ -23,6 +23,7 @@ trait QueryIRLeaf extends QueryIRNode // TODO can we source it from somewhere and not guess about this? // Current values were proposed on 2024-11-19 by Claude Sonnet 3.5 v20241022, and somewhat modifier later +// Maybe compare with https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-PRECEDENCE which appears to be a little different object Precedence { val Literal = 100 // literals, identifiers val ListOps = 95 // list_append, list_prepend, list_contains From fbc8420a662ce3e140b08e9d8655562dcac206b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sun, 1 Dec 2024 17:36:07 +0100 Subject: [PATCH 104/106] remove line that doesn't work on github CI --- src/main/scala/tyql/driver.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/scala/tyql/driver.scala b/src/main/scala/tyql/driver.scala index 9fc83f9..494d5c0 100644 --- a/src/main/scala/tyql/driver.scala +++ b/src/main/scala/tyql/driver.scala @@ -98,7 +98,7 @@ def driverMain(): Unit = { val zzz = db.run(t.filter(t => t.flowerSize.isNull || t.flowerSize >= 2)) println("received:") pprintln(zzz) - println("likes are " + zzz.head.likes.toString()) + // println("likes are " + zzz.head.likes.toString()) println("------------2------------") val zzz2 = db.run(t.map(_.name.getOrElse("UNKNOWN"))) From cf36b352d234858f9b596f9d36bc15c3fc826281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sun, 1 Dec 2024 18:00:13 +0100 Subject: [PATCH 105/106] casts to a predefined set of types --- src/main/scala/tyql/dialects/dialects.scala | 9 +++++ src/main/scala/tyql/expr/Expr.scala | 17 +++++++++ src/main/scala/tyql/ir/QueryIRTree.scala | 6 ++++ src/test/scala/test/integration/casts.scala | 39 +++++++++++++++++++++ 4 files changed, 71 insertions(+) create mode 100644 src/test/scala/test/integration/casts.scala diff --git a/src/main/scala/tyql/dialects/dialects.scala b/src/main/scala/tyql/dialects/dialects.scala index 0f342b2..e3e8fb7 100644 --- a/src/main/scala/tyql/dialects/dialects.scala +++ b/src/main/scala/tyql/dialects/dialects.scala @@ -36,6 +36,11 @@ trait Dialect: val nullSafeEqualityViaSpecialOperator: Boolean = false + val booleanCast: String = "BOOLEAN" + val integerCast: String = "INTEGER" + val doubleCast: String = "DOUBLE PRECISION" + val stringCast: String = "VARCHAR" + object Dialect: val literal_percent = '\uE000' val literal_underscore = '\uE001' @@ -94,6 +99,10 @@ object Dialect: val b = ("b", Precedence.Concat) SqlSnippet(Precedence.Unary, snippet"(with randomIntParameters as (select $a as a, $b as b) select floor(rand() * (b - a + 1) + a) from randomIntParameters)") override val nullSafeEqualityViaSpecialOperator: Boolean = true + override val booleanCast: String = "SIGNED" + override val integerCast: String = "DECIMAL" + override val doubleCast: String = "DOUBLE" + override val stringCast: String = "CHAR" given RandomUUID = new RandomUUID {} given RandomIntegerInInclusiveRange = new RandomIntegerInInclusiveRange {} diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index c8b8537..7198b1e 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -18,6 +18,9 @@ type CalculatedShape[S1 <: ExprShape, S2 <: ExprShape] <: ExprShape = S2 match trait CanBeEqualed[T1, T2] +private[tyql] enum CastTarget: + case CInt, CString, CDouble, CBool + /** The type of expressions in the query language */ trait Expr[Result, Shape <: ExprShape](using val tag: ResultTag[Result]) extends Selectable: /** This type is used to support selection with any of the field names @@ -51,6 +54,17 @@ trait Expr[Result, Shape <: ExprShape](using val tag: ResultTag[Result]) extends def isNull[S <: ExprShape]: Expr[Boolean, Shape] = Expr.IsNull(this) def nullIf[S <: ExprShape](other: Expr[Result, S]): Expr[Result, CalculatedShape[Shape, S]] = Expr.NullIf(this, other) + // TODO unclear what casts are useful/needed + // TODO why do we need these `asInstanceOf`? + @targetName("castToInt") + def as[T](using T =:= Int): Expr[Int, Shape] = Expr.Cast(this, CastTarget.CInt)(using ResultTag.IntTag.asInstanceOf[ResultTag[Int]]) + @targetName("castToString") + def as[T](using T =:= String): Expr[String, Shape] = Expr.Cast[Result, String, Shape](this, CastTarget.CString)(using ResultTag.StringTag.asInstanceOf[ResultTag[String]]) + @targetName("castToDouble") + def as[T](using T =:= Double): Expr[Double, Shape] = Expr.Cast[Result, Double, Shape](this, CastTarget.CDouble)(using ResultTag.DoubleTag.asInstanceOf[ResultTag[Double]]) + @targetName("castToBoolean") + def as[T](using T =:= Boolean): Expr[Boolean, Shape] = Expr.Cast[Result, Boolean, Shape](this, CastTarget.CBool)(using ResultTag.BoolTag.asInstanceOf[ResultTag[Boolean]]) + def cases[DestinationT: ResultTag, SV <: ExprShape](firstCase: (Expr[Result, Shape] | ElseToken, Expr[DestinationT, SV]), restOfCases: (Expr[Result, Shape] | ElseToken, Expr[DestinationT, SV])*): Expr[DestinationT, SV] = type FromT = Result var mainCases: collection.mutable.ArrayBuffer[(Expr[FromT, Shape], Expr[DestinationT, SV])] = collection.mutable.ArrayBuffer.empty @@ -328,6 +342,8 @@ object Expr: case class OptionMap[A, B, S <: ExprShape]($x: Expr[Option[A], S], $f: Ref[A, NonScalarExpr] => Expr[B, NonScalarExpr])(using ResultTag[A], ResultTag[B]) extends Expr[Option[B], S] + case class Cast[A, B, S <: ExprShape]($x: Expr[A, S], resultType: CastTarget)(using ResultTag[B]) extends Expr[B, S] + case class NullLit[A]()(using ResultTag[A]) extends Expr[A, NonScalarExpr] case class IsNull[A, S <: ExprShape]($x: Expr[A, S]) extends Expr[Boolean, S] case class Coalesce[A, S1 <: ExprShape]($x1: Expr[A, S1], $x2: Expr[A, S1], $xs: Seq[Expr[A, S1]])(using ResultTag[A]) extends Expr[A, S1] @@ -413,6 +429,7 @@ end Expr def lit(x: Int): Expr[Int, NonScalarExpr] = Expr.IntLit(x) def lit(x: Double): Expr[Double, NonScalarExpr] = Expr.DoubleLit(x) def lit(x: String): Expr[String, NonScalarExpr] = Expr.StringLit(x) +def lit(x: Boolean): Expr[Boolean, NonScalarExpr] = Expr.BooleanLit(x) def True = Expr.BooleanLit(true) def False = Expr.BooleanLit(false) def Null = Expr.NullLit[scala.Null]() diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index 6780031..8424ba6 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -339,6 +339,12 @@ object QueryIRTree: QueryIRVar(sub, name, ref) // TODO: singleton? case s: Expr.Select[?] => SelectExpr(s.$name, generateExpr(s.$x, symbols), s) case p: Expr.Project[?] => generateProjection(p, symbols) + case c: Expr.Cast[?, ?, ?] => + c.resultType match + case CastTarget.CString => UnaryExprOp(generateExpr(c.$x, symbols), o => s"CAST($o AS ${d.stringCast})", c) + case CastTarget.CBool => UnaryExprOp(generateExpr(c.$x, symbols), o => s"CAST($o AS ${d.booleanCast})", c) + case CastTarget.CDouble => UnaryExprOp(generateExpr(c.$x, symbols), o => s"CAST($o AS ${d.doubleCast})", c) + case CastTarget.CInt => UnaryExprOp(generateExpr(c.$x, symbols), o => s"CAST($o AS ${d.integerCast})", c) case g: Expr.Gt[?, ?, ?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l > $r", Precedence.Comparison, g) case g: Expr.Lt[?, ?, ?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l < $r", Precedence.Comparison, g) case g: Expr.Lte[?, ?, ?, ?] => BinExprOp(generateExpr(g.$x, symbols), generateExpr(g.$y, symbols), (l, r) => s"$l <= $r", Precedence.Comparison, g) diff --git a/src/test/scala/test/integration/casts.scala b/src/test/scala/test/integration/casts.scala new file mode 100644 index 0000000..de49c17 --- /dev/null +++ b/src/test/scala/test/integration/casts.scala @@ -0,0 +1,39 @@ +package test.integration + +import munit.FunSuite +import tyql.* +import test.{checkExprDialect, withDB} +import scala.language.implicitConversions +import java.sql.ResultSet + +class CastTests extends FunSuite { + def expectI(expected: Int)(rs: ResultSet) = assertEquals(rs.getInt(1), expected) + def expectD(expected: Double)(rs: ResultSet) = assertEquals(rs.getDouble(1), expected) + def expectS(expected: String)(rs: ResultSet) = assertEquals(rs.getString(1), expected) + def expectB(expected: Boolean)(rs: ResultSet) = assertEquals(rs.getBoolean(1), expected) + + test("parsing integers") { + checkExprDialect[Int](lit("101").as[Int], expectI(101))(withDB.all) + checkExprDialect[Int](lit("-200").as[Int], expectI(-200))(withDB.all) + checkExprDialect[Int](lit("-0").as[Int], expectI(0))(withDB.all) + checkExprDialect[Int](lit("0").as[Int], expectI(0))(withDB.all) + } + + test("parsing doubles") { + checkExprDialect[Double](lit("101.6").as[Double], expectD(101.6))(withDB.all) + checkExprDialect[Double](lit("-601.6").as[Double], expectD(-601.6))(withDB.all) + checkExprDialect[Double](lit("-0.0").as[Double], expectD(-0.0))(withDB.all) + checkExprDialect[Double](lit("0.001").as[Double], expectD(0.001))(withDB.all) + } + + test("toString") { + checkExprDialect[String](lit(1056).as[String], expectS("1056"))(withDB.all) + checkExprDialect[String](lit(-123.123).as[String], expectS("-123.123"))(withDB.all) + checkExprDialect[String](lit("???Aałajć").as[String], expectS("???Aałajć"))(withDB.all) + } + + test("booleans are interpreted as numbers") { + checkExprDialect[Int](lit(true).as[Int], expectI(1))(withDB.all) + checkExprDialect[Int](lit(false).as[Int], expectI(0))(withDB.all) + } +} From 2f8cca631d0c03b31194b8629278860e5863dfd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20Bia=C5=82as?= Date: Sun, 1 Dec 2024 18:06:53 +0100 Subject: [PATCH 106/106] a comment --- src/main/scala/tyql/expr/Expr.scala | 4 +++- src/main/scala/tyql/ir/QueryIRTree.scala | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/scala/tyql/expr/Expr.scala b/src/main/scala/tyql/expr/Expr.scala index 7198b1e..6360573 100644 --- a/src/main/scala/tyql/expr/Expr.scala +++ b/src/main/scala/tyql/expr/Expr.scala @@ -37,9 +37,12 @@ trait Expr[Result, Shape <: ExprShape](using val tag: ResultTag[Result]) extends /** Member methods to implement universal equality on Expr level. */ def ==[T, S <: ExprShape](other: Expr[T, S])(using CanBeEqualed[Result, T]): Expr[Boolean, CalculatedShape[Shape, S]] = Expr.Eq[Shape, S](this, other) def ===[T, S <: ExprShape](other: Expr[T, S])(using CanBeEqualed[Result, T]): Expr[Boolean, CalculatedShape[Shape, S]] = Expr.NullSafeEq[Shape, S](this, other) + + // XXX these are ugly, but hard to remove, since we are running in live Scala, the compiler likes to interpret `==` as a native equality and complain def ==(other: String): Expr[Boolean, Shape] = Expr.Eq(this, Expr.StringLit(other)) def ==(other: Int): Expr[Boolean, Shape] = Expr.Eq(this, Expr.IntLit(other)) def ==(other: Boolean): Expr[Boolean, Shape] = Expr.Eq(this, Expr.BooleanLit(other)) + def ==(other: Double): Expr[Boolean, Shape] = Expr.Eq(this, Expr.DoubleLit(other)) @targetName("neqNonScalar") def != [T](other: Expr[T, NonScalarExpr])(using CanBeEqualed[Result, T]): Expr[Boolean, Shape] = Expr.Ne[Shape, NonScalarExpr](this, other) @@ -50,7 +53,6 @@ trait Expr[Result, Shape <: ExprShape](using val tag: ResultTag[Result]) extends @targetName("nullSafeNeqScalar") def !== [T](other: Expr[T, ScalarExpr])(using CanBeEqualed[Result, T]): Expr[Boolean, ScalarExpr] = Expr.NullSafeNe[Shape, ScalarExpr](this, other) - def isNull[S <: ExprShape]: Expr[Boolean, Shape] = Expr.IsNull(this) def nullIf[S <: ExprShape](other: Expr[Result, S]): Expr[Result, CalculatedShape[Shape, S]] = Expr.NullIf(this, other) diff --git a/src/main/scala/tyql/ir/QueryIRTree.scala b/src/main/scala/tyql/ir/QueryIRTree.scala index 8424ba6..72ec118 100644 --- a/src/main/scala/tyql/ir/QueryIRTree.scala +++ b/src/main/scala/tyql/ir/QueryIRTree.scala @@ -6,7 +6,6 @@ import tyql.ResultTag.NamedTupleTag import language.experimental.namedTuples import NamedTuple.NamedTuple import NamedTupleDecomposition.* -import org.checkerframework.checker.units.qual.m /** * Logical query plan tree.