├── .devcontainer ├── direnv.toml ├── devcontainer.json └── docker-compose.yml ├── .haxerc ├── .vscode ├── php.hxml ├── node.hxml ├── settings.json └── tasks.json ├── tests ├── fixture │ ├── init_sqlite.sql │ ├── init_cockroachdb.sql │ ├── init_postgresql.sql │ ├── init_mysql.sql │ ├── schema_empty.sql │ ├── procedure.sql │ ├── schema_modify.sql │ ├── schema_prefilled.sql │ ├── schema_identical.sql │ └── schema_indexes.sql ├── TestWithDb.hx ├── TruncateTest.hx ├── DateTest.hx ├── TestIssue104.hx ├── docker-compose.yml ├── TransactionTest.hx ├── ProcedureTest.hx ├── ConnectionTest.hx ├── StringTest.hx ├── BigIntTest.hx ├── TypeTest.hx ├── InsertIgnoreTest.hx ├── SelectTest.hx ├── SchemaTest.hx ├── UpsertTest.hx ├── IdTest.hx ├── GeometryTest.hx ├── ExprTest.hx ├── JsonTest.hx ├── SubQueryTest.hx ├── Db.hx └── FormatTest.hx ├── src └── tink │ └── sql │ ├── Database.hx │ ├── DatabaseDefinition.hx │ ├── Selection.hx │ ├── format │ ├── Sanitizer.hx │ ├── Formatter.hx │ ├── SqliteFormatter.hx │ ├── Statement.hx │ ├── PostgreSqlFormatter.hx │ └── CockroachDbFormatter.hx │ ├── Fields.hx │ ├── Results.hx │ ├── drivers │ ├── java │ │ └── JavaDriver.hx │ ├── MySqlSettings.hx │ ├── PostgreSqlSettings.hx │ ├── CockroachDbSettings.hx │ ├── Sqlite.hx │ ├── macro │ │ └── Dummy.hx │ ├── sys │ │ ├── Sqlite.hx │ │ ├── MySql.hx │ │ └── StdConnection.hx │ ├── MySql.hx │ ├── node │ │ ├── externs │ │ │ └── PostgreSql.hx │ │ ├── Sqlite3.hx │ │ ├── CockroachDb.hx │ │ └── PostgreSql.hx │ └── php │ │ └── PDO.hx │ ├── OrderBy.hx │ ├── macros │ ├── Groups.hx │ ├── Filters.hx │ ├── ProcedureBuilder.hx │ ├── Helper.hx │ ├── Selects.hx │ ├── Targets.hx │ └── Joins.hx │ ├── Driver.hx │ ├── Limit.hx │ ├── Target.hx │ ├── Connection.hx │ ├── Procedure.hx │ ├── Fields.macro.hx │ ├── schema │ └── KeyStore.hx │ ├── DatabaseInfo.hx │ ├── Transaction.hx │ ├── Info.hx │ ├── Results.macro.hx │ ├── Database.macro.hx │ ├── DatabaseDefinition.macro.hx │ ├── Transaction.macro.hx │ ├── expr │ ├── Functions.hx │ └── ExprTyper.hx │ ├── Query.hx │ ├── Schema.hx │ ├── parse │ └── ResultParser.hx │ ├── Types.hx │ └── Table.hx ├── .gitignore ├── haxe_libraries ├── tink_sql.hxml ├── ansi.hxml ├── tink_chunk.hxml ├── tink_io.hxml ├── tink_stringly.hxml ├── jdbc.mysql.hxml ├── tink_cli.hxml ├── tink_core.hxml ├── tink_spatial.hxml ├── tink_macro.hxml ├── tink_priority.hxml ├── tink_url.hxml ├── hxnodejs.hxml ├── tink_await.hxml ├── tink_syntaxhub.hxml ├── tink_streams.hxml ├── tink_unittest.hxml ├── tink_testrunner.hxml └── travix.hxml ├── tests.hxml ├── package.json ├── haxelib.json ├── LICENSE ├── .github └── workflows │ └── main.yml ├── README.md └── Earthfile /.devcontainer/direnv.toml: -------------------------------------------------------------------------------- 1 | [whitelist] 2 | prefix = [ "/workspace" ] -------------------------------------------------------------------------------- /.haxerc: -------------------------------------------------------------------------------- 1 | { 2 | "version": "4.2.1", 3 | "resolveLibs": "scoped" 4 | } -------------------------------------------------------------------------------- /.vscode/php.hxml: -------------------------------------------------------------------------------- 1 | -lib travix 2 | -lib tink_sql 3 | tests.hxml 4 | -php bin/php -------------------------------------------------------------------------------- /tests/fixture/init_sqlite.sql: -------------------------------------------------------------------------------- 1 | -- no-op, just to test executeSql() 2 | SELECT 1; 3 | -------------------------------------------------------------------------------- /tests/fixture/init_cockroachdb.sql: -------------------------------------------------------------------------------- 1 | -- no-op, just to test executeSql() 2 | SELECT 1; 3 | -------------------------------------------------------------------------------- /tests/fixture/init_postgresql.sql: -------------------------------------------------------------------------------- 1 | -- no-op, just to test executeSql() 2 | SELECT 1; 3 | -------------------------------------------------------------------------------- /tests/fixture/init_mysql.sql: -------------------------------------------------------------------------------- 1 | DROP DATABASE IF EXISTS test; 2 | CREATE DATABASE test; 3 | USE test; 4 | -------------------------------------------------------------------------------- /.vscode/node.hxml: -------------------------------------------------------------------------------- 1 | -lib travix 2 | -lib tink_sql 3 | tests.hxml 4 | -lib hxnodejs 5 | -js bin/node/tests.js -------------------------------------------------------------------------------- /src/tink/sql/Database.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | @:genericBuild(tink.sql.Database.build()) 4 | class Database {} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin 2 | /node_modules 3 | /yarn.lock 4 | 5 | .envrc 6 | 7 | .tmp-earthly-out 8 | 9 | .DS_Store 10 | -------------------------------------------------------------------------------- /haxe_libraries/tink_sql.hxml: -------------------------------------------------------------------------------- 1 | -cp src 2 | 3 | -lib tink_streams 4 | -lib tink_macro 5 | -lib tink_url 6 | -lib tink_spatial -------------------------------------------------------------------------------- /tests.hxml: -------------------------------------------------------------------------------- 1 | -D no-deprecation-warnings 2 | -lib jdbc.mysql 3 | -lib tink_unittest 4 | -lib tink_await 5 | -cp tests 6 | -main Run -------------------------------------------------------------------------------- /tests/fixture/schema_empty.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS test.Schema; 2 | CREATE TABLE test.Schema ( 3 | `remove` tinyint(1) 4 | ); -------------------------------------------------------------------------------- /src/tink/sql/DatabaseDefinition.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | @:autoBuild(tink.sql.DatabaseDefinition.build()) 4 | interface DatabaseDefinition {} -------------------------------------------------------------------------------- /haxe_libraries/ansi.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download haxelib:ansi#1.0.0 into ansi/1.0.0/haxelib 2 | -D ansi=1.0.0 3 | -cp ${HAXESHIM_LIBCACHE}/ansi/1.0.0/haxelib/src 4 | -------------------------------------------------------------------------------- /src/tink/sql/Selection.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | import haxe.DynamicAccess; 4 | using tink.CoreApi; 5 | 6 | typedef Selection = DynamicAccess>; -------------------------------------------------------------------------------- /src/tink/sql/format/Sanitizer.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.format; 2 | 3 | using tink.CoreApi; 4 | 5 | interface Sanitizer { 6 | function value(v:Any):String; 7 | function ident(s:String):String; 8 | } -------------------------------------------------------------------------------- /haxe_libraries/tink_chunk.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download "haxelib:/tink_chunk#0.3.1" into tink_chunk/0.3.1/haxelib 2 | -cp ${HAXE_LIBCACHE}/tink_chunk/0.3.1/haxelib/src 3 | -D tink_chunk=0.3.1 -------------------------------------------------------------------------------- /src/tink/sql/Fields.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | /** 4 | * Convert each member in a Model object into a tink.sql.Field instance 5 | */ 6 | @:genericBuild(tink.sql.Fields.build()) 7 | class Fields {} -------------------------------------------------------------------------------- /src/tink/sql/Results.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | /** 4 | * Convert each member in a Model object into a readonly property 5 | */ 6 | @:genericBuild(tink.sql.Results.build()) 7 | class Results {} -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "haxe.configurations": [ 3 | [".vscode/node.hxml"], 4 | [".vscode/php.hxml"] 5 | ], 6 | "editor.tabSize": 2, 7 | "editor.insertSpaces": true 8 | } 9 | -------------------------------------------------------------------------------- /src/tink/sql/drivers/java/JavaDriver.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.drivers.java; 2 | 3 | /** 4 | * ... 5 | * @author 6 | */ 7 | class JavaDriver{ 8 | 9 | public function new() { 10 | 11 | } 12 | 13 | } -------------------------------------------------------------------------------- /src/tink/sql/OrderBy.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | import tink.sql.Expr; 4 | 5 | typedef OrderBy = Array<{field:Field, order:Order}>; 6 | 7 | 8 | enum Order { 9 | Asc; 10 | Desc; 11 | } -------------------------------------------------------------------------------- /src/tink/sql/macros/Groups.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.macros; 2 | 3 | import haxe.macro.Expr; 4 | 5 | class Groups { 6 | static public function groupBy(dataset:Expr, columns:Array) { 7 | return dataset; 8 | } 9 | } -------------------------------------------------------------------------------- /haxe_libraries/tink_io.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download "haxelib:/tink_io#0.8.0" into tink_io/0.8.0/haxelib 2 | -lib tink_chunk 3 | -lib tink_streams 4 | -cp ${HAXE_LIBCACHE}/tink_io/0.8.0/haxelib/src 5 | -D tink_io=0.8.0 -------------------------------------------------------------------------------- /haxe_libraries/tink_stringly.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download "haxelib:/tink_stringly#0.4.0" into tink_stringly/0.4.0/haxelib 2 | -lib tink_core 3 | -cp ${HAXE_LIBCACHE}/tink_stringly/0.4.0/haxelib/src 4 | -D tink_stringly=0.4.0 -------------------------------------------------------------------------------- /haxe_libraries/jdbc.mysql.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download haxelib:jdbc.mysql#5.0.8 into jdbc.mysql/5.0.8/haxelib 2 | -D jdbc.mysql=5.0.8 3 | -cp ${HAXESHIM_LIBCACHE}/jdbc.mysql/5.0.8/haxelib/src 4 | --macro jdbc.mysql.Include.library() -------------------------------------------------------------------------------- /tests/fixture/procedure.sql: -------------------------------------------------------------------------------- 1 | DROP PROCEDURE IF EXISTS test.func; 2 | 3 | CREATE PROCEDURE test.func(x INT(10)) 4 | BEGIN 5 | SELECT x AS `x`, ST_GeomFromText('POINT(2.0 1.0)',4326) AS `point` UNION ALL SELECT x + 1, ST_GeomFromText('POINT(3.0 2.0)',4326) ; 6 | END ; -------------------------------------------------------------------------------- /src/tink/sql/drivers/MySqlSettings.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.drivers; 2 | 3 | typedef MySqlSettings = { 4 | final user:String; 5 | final password:String; 6 | final ?charset:String; 7 | final ?host:String; 8 | final ?port:Int; 9 | final ?timezone:String; 10 | } 11 | -------------------------------------------------------------------------------- /src/tink/sql/drivers/PostgreSqlSettings.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.drivers; 2 | 3 | typedef PostgreSqlSettings = { 4 | @:optional var host(default, null):String; 5 | @:optional var port(default, null):Int; 6 | var user(default, null):String; 7 | var password(default, null):String; 8 | } 9 | -------------------------------------------------------------------------------- /src/tink/sql/drivers/CockroachDbSettings.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.drivers; 2 | 3 | typedef CockroachDbSettings = { 4 | @:optional var host(default, null):String; 5 | @:optional var port(default, null):Int; 6 | var user(default, null):String; 7 | var password(default, null):String; 8 | } 9 | -------------------------------------------------------------------------------- /tests/TestWithDb.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import tink.sql.Driver; 4 | import tink.sql.Database; 5 | 6 | class TestWithDb { 7 | 8 | var driver:Driver; 9 | var db:Db; 10 | 11 | public function new(driver, db) { 12 | this.driver = driver; 13 | this.db = db; 14 | } 15 | 16 | } 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tink_sql", 3 | "scripts": { 4 | "test": "lix run travix", 5 | "postinstall": "lix download" 6 | }, 7 | "dependencies": { 8 | "lix": "^15.9.0", 9 | "mysql": "^2.18.1", 10 | "pg": "^8.7.3", 11 | "sqlite3": "^5.0.0" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /haxe_libraries/tink_cli.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download "haxelib:/tink_cli#0.5.1" into tink_cli/0.5.1/haxelib 2 | -lib tink_io 3 | -lib tink_macro 4 | -lib tink_stringly 5 | -cp ${HAXE_LIBCACHE}/tink_cli/0.5.1/haxelib/src 6 | -D tink_cli=0.5.1 7 | # Make sure docs are generated 8 | -D use-rtti-doc -------------------------------------------------------------------------------- /haxe_libraries/tink_core.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download "gh://github.com/haxetink/tink_core#8fb0b9aa4de933614b5a04cc88da871b89cb8c6a" into tink_core/2.0.2/github/8fb0b9aa4de933614b5a04cc88da871b89cb8c6a 2 | -cp ${HAXE_LIBCACHE}/tink_core/2.0.2/github/8fb0b9aa4de933614b5a04cc88da871b89cb8c6a/src 3 | -D tink_core=2.0.2 -------------------------------------------------------------------------------- /src/tink/sql/drivers/Sqlite.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.drivers; 2 | 3 | typedef Sqlite = 4 | #if macro 5 | tink.sql.drivers.macro.Dummy; 6 | #elseif nodejs 7 | tink.sql.drivers.node.Sqlite3; 8 | #elseif php 9 | tink.sql.drivers.php.PDO.PDOSqlite; 10 | #else 11 | tink.sql.drivers.sys.Sqlite; 12 | #end -------------------------------------------------------------------------------- /haxe_libraries/tink_spatial.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download "gh://github.com/haxetink/tink_spatial#fb0d0a02ceed49325709215b5da0062fd8316c62" into tink_spatial/0.1.0/github/fb0d0a02ceed49325709215b5da0062fd8316c62 2 | -cp ${HAXE_LIBCACHE}/tink_spatial/0.1.0/github/fb0d0a02ceed49325709215b5da0062fd8316c62/src 3 | -D tink_spatial=0.1.0 -------------------------------------------------------------------------------- /src/tink/sql/Driver.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | import tink.sql.Info.DatabaseInfo; 4 | interface Driver { 5 | final type:DriverType; 6 | function open(name:String, info:DatabaseInfo):Connection.ConnectionPool; 7 | } 8 | 9 | enum DriverType { 10 | MySql; 11 | PostgreSql; 12 | CockroachDb; 13 | Sqlite; 14 | } -------------------------------------------------------------------------------- /haxe_libraries/tink_macro.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download "gh://github.com/haxetink/tink_macro#f3ddaa6496e3d0e82696c3ac9a7ccefac16954d4" into tink_macro/0.21.1/github/f3ddaa6496e3d0e82696c3ac9a7ccefac16954d4 2 | -lib tink_core 3 | -cp ${HAXE_LIBCACHE}/tink_macro/0.21.1/github/f3ddaa6496e3d0e82696c3ac9a7ccefac16954d4/src 4 | -D tink_macro=0.21.1 -------------------------------------------------------------------------------- /haxe_libraries/tink_priority.hxml: -------------------------------------------------------------------------------- 1 | -D tink_priority=0.1.4 2 | # @install: lix --silent download "gh://github.com/haxetink/tink_priority#b4b772298676314d0672d64ddc8e7f4ffece0f1b" into tink_priority/0.1.4/github/b4b772298676314d0672d64ddc8e7f4ffece0f1b 3 | -cp ${HAXE_LIBCACHE}/tink_priority/0.1.4/github/b4b772298676314d0672d64ddc8e7f4ffece0f1b/src 4 | -------------------------------------------------------------------------------- /haxe_libraries/tink_url.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download https://github.com/haxetink/tink_url/archive/7c98a2ea212c3a2f8d9ff41c1450eae98eee812f.tar.gz into tink_url/0.3.1/github/7c98a2ea212c3a2f8d9ff41c1450eae98eee812f 2 | -D tink_url=0.3.1 3 | -cp ${HAXESHIM_LIBCACHE}/tink_url/0.3.1/github/7c98a2ea212c3a2f8d9ff41c1450eae98eee812f/src 4 | 5 | -lib tink_stringly -------------------------------------------------------------------------------- /haxe_libraries/hxnodejs.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download "haxelib:/hxnodejs#12.1.0" into hxnodejs/12.1.0/haxelib 2 | -cp ${HAXE_LIBCACHE}/hxnodejs/12.1.0/haxelib/src 3 | -D hxnodejs=12.1.0 4 | --macro allowPackage('sys') 5 | # should behave like other target defines and not be defined in macro context 6 | --macro define('nodejs') 7 | --macro _internal.SuppressDeprecated.run() 8 | -------------------------------------------------------------------------------- /tests/fixture/schema_modify.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS test.Schema; 2 | CREATE TABLE test.Schema ( 3 | `id` int(12) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, 4 | `toBoolean` float NOT NULL, 5 | `toFloat` int(11) UNSIGNED NOT NULL, 6 | `toInt` tinyint(1) UNSIGNED NOT NULL, 7 | `toLongText` tinyint(1) NOT NULL, 8 | `toText` text NOT NULL, 9 | `toDate` tinyint(1) NOT NULL 10 | ); -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "shell", 6 | "command": "lix run travix node", 7 | "problemMatcher": [ 8 | "$haxe-absolute", 9 | "$haxe", 10 | "$haxe-error", 11 | "$haxe-trace" 12 | ], 13 | "group": { 14 | "kind": "build", 15 | "isDefault": true 16 | }, 17 | "label": "Run Tests" 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /haxe_libraries/tink_await.hxml: -------------------------------------------------------------------------------- 1 | -D tink_await=0.5.0 2 | # @install: lix --silent download "gh://github.com/haxetink/tink_await#72b0854655911a71976297a62c35efc950b6db59" into tink_await/0.5.0/github/72b0854655911a71976297a62c35efc950b6db59 3 | -lib tink_core 4 | -lib tink_macro 5 | -lib tink_syntaxhub 6 | -cp ${HAXE_LIBCACHE}/tink_await/0.5.0/github/72b0854655911a71976297a62c35efc950b6db59/src 7 | --macro tink.await.Await.use() -------------------------------------------------------------------------------- /haxe_libraries/tink_syntaxhub.hxml: -------------------------------------------------------------------------------- 1 | -D tink_syntaxhub=0.4.2 2 | # @install: lix --silent download "gh://github.com/haxetink/tink_syntaxhub#8e4aed73a6922bc3f9c299a7b1b9809a18559581" into tink_syntaxhub/0.4.2/github/8e4aed73a6922bc3f9c299a7b1b9809a18559581 3 | -lib tink_priority 4 | -lib tink_macro 5 | -cp ${HAXE_LIBCACHE}/tink_syntaxhub/0.4.2/github/8e4aed73a6922bc3f9c299a7b1b9809a18559581/src 6 | --macro tink.SyntaxHub.use() -------------------------------------------------------------------------------- /tests/fixture/schema_prefilled.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS test.Schema; 2 | CREATE TABLE test.Schema ( 3 | `toBoolean` float NOT NULL, 4 | `toFloat` int(11) NOT NULL, 5 | `toInt` tinyint(1) UNSIGNED NOT NULL, 6 | `toLongText` tinyint(1) NOT NULL, 7 | `toText` text NOT NULL, 8 | `toDate` text NOT NULL 9 | ); 10 | 11 | INSERT INTO test.Schema VALUES ('1.123456', '123', '1', '0', 'A', '2018-01-01 00:00:00'); -------------------------------------------------------------------------------- /haxe_libraries/tink_streams.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download "gh://github.com/haxetink/tink_streams#c51ff28d69ea844995696f10575d1a150ce47159" into tink_streams/0.3.3/github/c51ff28d69ea844995696f10575d1a150ce47159 2 | -lib tink_core 3 | -cp ${HAXE_LIBCACHE}/tink_streams/0.3.3/github/c51ff28d69ea844995696f10575d1a150ce47159/src 4 | -D tink_streams=0.3.3 5 | # temp for development, delete this file when pure branch merged 6 | -D pure -------------------------------------------------------------------------------- /haxe_libraries/tink_unittest.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download "gh://github.com/haxetink/tink_unittest#59c49c39a4b5411f240ab8e4c440badde7fea08d" into tink_unittest/0.8.0/github/59c49c39a4b5411f240ab8e4c440badde7fea08d 2 | -lib tink_syntaxhub 3 | -lib tink_testrunner 4 | -cp ${HAXE_LIBCACHE}/tink_unittest/0.8.0/github/59c49c39a4b5411f240ab8e4c440badde7fea08d/src 5 | -D tink_unittest=0.8.0 6 | --macro tink.unit.AssertionBufferInjector.use() -------------------------------------------------------------------------------- /haxelib.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tink_sql", 3 | "url" : "http://haxetink.org/tink_sql", 4 | "license": "MIT", 5 | "tags": ["tink", "sql", "cross"], 6 | "description": "", 7 | "version": "0.0.0-alpha.0", 8 | "dependencies": { 9 | "tink_streams": "", 10 | "tink_macro": "", 11 | "tink_url": "", 12 | "tink_spatial": "" 13 | }, 14 | "releasenote": "Initial release", 15 | "contributors": ["back2dos"], 16 | "classPath": "src" 17 | } 18 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tink_sql", 3 | "dockerComposeFile": "docker-compose.yml", 4 | "service": "workspace", 5 | "workspaceFolder": "/workspace", 6 | "remoteEnv": { 7 | "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}" 8 | }, 9 | "settings": {}, 10 | "extensions": [ 11 | "ms-azuretools.vscode-docker", 12 | "nadako.vshaxe", 13 | "earthly.earthfile-syntax-highlighting", 14 | ], 15 | "remoteUser": "vscode" 16 | } -------------------------------------------------------------------------------- /src/tink/sql/drivers/macro/Dummy.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.drivers.macro; 2 | 3 | import tink.sql.Driver; 4 | import tink.sql.Info; 5 | 6 | class Dummy implements Driver { 7 | public final type:DriverType = null; 8 | public function new(_:Dynamic) {} 9 | public function open(name:String, info:DatabaseInfo):Connection.ConnectionPool throw 'dummy'; 10 | public function executeSql(sql:String):tink.core.Promise throw 'dummy'; 11 | } -------------------------------------------------------------------------------- /src/tink/sql/Limit.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | private typedef LimitData = { 4 | var limit(default, null):Int; 5 | var offset(default, null):Int; 6 | } 7 | 8 | @:forward 9 | abstract Limit(LimitData) from LimitData to LimitData { 10 | 11 | @:from static function ofIter(i:IntIterator):Limit 12 | return @:privateAccess { limit: i.max - i.min, offset: i.min }; 13 | 14 | @:from static function ofInt(i:Int):Limit 15 | return { limit: i, offset: 0 }; 16 | } -------------------------------------------------------------------------------- /haxe_libraries/tink_testrunner.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download "gh://github.com/haxetink/tink_testrunner#45f704215ae28c3d864755036dc2ee63f7c44e8a" into tink_testrunner/0.9.0/github/45f704215ae28c3d864755036dc2ee63f7c44e8a 2 | -lib ansi 3 | -lib tink_macro 4 | -lib tink_streams 5 | -cp ${HAXE_LIBCACHE}/tink_testrunner/0.9.0/github/45f704215ae28c3d864755036dc2ee63f7c44e8a/src 6 | -D tink_testrunner=0.9.0 7 | --macro addGlobalMetadata('ANSI.Attribute', "@:native('ANSIAttribute')", false) -------------------------------------------------------------------------------- /src/tink/sql/format/Formatter.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.format; 2 | 3 | import tink.sql.Info; 4 | 5 | interface Formatter { 6 | function format(query:Query):Statement; 7 | function defineColumn(column:Column):Statement; 8 | function defineKey(key:Key):Statement; 9 | function isNested(query:Query):Bool; 10 | function parseColumn(col:ColInfo):Column; 11 | function parseKeys(keys:Array):Array; 12 | } -------------------------------------------------------------------------------- /src/tink/sql/Target.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | import tink.sql.Expr; 4 | import tink.sql.Table; 5 | import tink.sql.Info; 6 | 7 | using tink.CoreApi; 8 | 9 | @:enum abstract JoinType(String) { 10 | var Inner = null; 11 | var Left = 'left'; 12 | var Right = 'right'; 13 | //var Outer = 'outer'; //somehow MySQL can't do this. I don't blame them 14 | } 15 | 16 | enum Target { 17 | TTable(table:TableInfo); 18 | TJoin(left:Target, right:Target, type:JoinType, c:Condition); 19 | TQuery(alias:String, query:Query); 20 | } -------------------------------------------------------------------------------- /haxe_libraries/travix.hxml: -------------------------------------------------------------------------------- 1 | # @install: lix --silent download "gh://github.com/back2dos/travix#812c4abebe765ec63f94b9b5e5c8f861a174b28a" into travix/0.15.0/github/812c4abebe765ec63f94b9b5e5c8f861a174b28a 2 | # @post-install: cd ${HAXE_LIBCACHE}/travix/0.15.0/github/812c4abebe765ec63f94b9b5e5c8f861a174b28a && haxe -cp src --run travix.PostDownload 3 | # @run: haxelib run-dir travix ${HAXE_LIBCACHE}/travix/0.15.0/github/812c4abebe765ec63f94b9b5e5c8f861a174b28a 4 | -lib tink_cli 5 | -cp ${HAXE_LIBCACHE}/travix/0.15.0/github/812c4abebe765ec63f94b9b5e5c8f861a174b28a/src 6 | -D travix=0.15.0 7 | --macro travix.Macro.setup() -------------------------------------------------------------------------------- /tests/TruncateTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | @:asserts 4 | class TruncateTest extends TestWithDb { 5 | 6 | @:before @:access(Run) 7 | public function before() 8 | return db.User.create() 9 | .next(_ -> new Run(driver, db).insertUsers()); 10 | 11 | @:after 12 | public function after() 13 | return db.User.drop(); 14 | 15 | public function truncate() { 16 | db.User.count() 17 | .next(count -> { 18 | asserts.assert(count > 0); 19 | db.User.truncate(); 20 | }) 21 | .next(_ -> db.User.count()) 22 | .next(count -> asserts.assert(count == 0)) 23 | .handle(asserts.handle); 24 | 25 | return asserts; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/tink/sql/Connection.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | import tink.sql.Query; 4 | import tink.sql.format.Formatter; 5 | 6 | using tink.CoreApi; 7 | 8 | interface ConnectionPool extends Connection { 9 | function isolate():Pair, CallbackLink>; 10 | } 11 | interface Connection { 12 | function getFormatter():Formatter<{}, {}>; 13 | function execute(query:Query):Result; 14 | 15 | /** 16 | * Run a raw SQL string. 17 | * It returns a Promise that will either resolve or be rejected, but no query result. 18 | * Use it for db initialization/cleanup etc. 19 | * Use `execute()` instead if possible. 20 | */ 21 | function executeSql(sql:String):tink.core.Promise; 22 | } -------------------------------------------------------------------------------- /src/tink/sql/macros/Filters.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.macros; 2 | 3 | import haxe.macro.Context; 4 | import haxe.macro.Expr; 5 | using tink.MacroApi; 6 | 7 | class Filters { 8 | static public function getArgs(e:Expr) 9 | return switch Context.typeof(macro @:pos(e.pos) { 10 | var source = $e; 11 | var x = null; 12 | @:privateAccess source._where(x); 13 | x; 14 | }).reduce() { 15 | case TFun(args, ret): 16 | args; 17 | case v: 18 | throw 'assert'; 19 | } 20 | 21 | static public function makeFilter(dataset:Expr, filter:Null) 22 | return 23 | switch filter { 24 | case macro null: filter; 25 | case { expr: EFunction(_, _) } : filter; 26 | default: 27 | filter.func([for (a in getArgs(dataset)) { name: a.name, type: a.t.toComplex({ direct: true }) } ]).asExpr(); 28 | } 29 | } -------------------------------------------------------------------------------- /src/tink/sql/Procedure.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | import tink.streams.RealStream; 4 | 5 | #if macro 6 | import haxe.macro.Expr; 7 | using haxe.macro.Tools; 8 | using tink.MacroApi; 9 | #else 10 | @:genericBuild(tink.sql.macros.ProcedureBuilder.build()) 11 | class Procedure {} 12 | #end 13 | 14 | class ProcedureBase { 15 | var name:String; 16 | var cnx:Connection; 17 | public function new(cnx, name) { 18 | this.cnx = cnx; 19 | this.name = name; 20 | } 21 | } 22 | 23 | class Called extends Dataset { 24 | var name:String; 25 | var args:Array>; 26 | public function new(cnx, name, args) { 27 | super(cnx); 28 | this.name = name; 29 | this.args = args; 30 | } 31 | 32 | override function toQuery(?limit):Query> 33 | return CallProcedure({ 34 | name: name, 35 | arguments: args, 36 | limit: limit, 37 | }); 38 | } -------------------------------------------------------------------------------- /tests/DateTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import Db; 4 | import tink.sql.Info; 5 | import tink.sql.Expr.Functions.*; 6 | import tink.unit.Assert.assert; 7 | 8 | using tink.CoreApi; 9 | 10 | @:asserts 11 | @:allow(tink.unit) 12 | class DateTest extends TestWithDb { 13 | 14 | @:before 15 | public function createTable() { 16 | return db.TimestampTypes.create(); 17 | } 18 | 19 | @:after 20 | public function dropTable() { 21 | return db.TimestampTypes.drop(); 22 | } 23 | 24 | public function insert() { 25 | var d = new Date(2000, 0, 1, 0, 0, 0); 26 | var future = db.TimestampTypes.insertOne({ 27 | timestamp: d, 28 | }) 29 | .next(function(_) return db.TimestampTypes.where(r -> r.timestamp == d).first()) 30 | .next(function(row:TimestampTypes) { 31 | asserts.assert(row.timestamp.getTime() == d.getTime()); 32 | return Noise; 33 | }); 34 | 35 | future.handle(function(o) switch o { 36 | case Success(_): asserts.done(); 37 | case Failure(e): asserts.fail(e); 38 | }); 39 | 40 | return asserts; 41 | } 42 | } -------------------------------------------------------------------------------- /tests/TestIssue104.hx: -------------------------------------------------------------------------------- 1 | import tink.sql.Database; 2 | 3 | import tink.unit.Assert.assert; 4 | 5 | import tink.sql.Types; 6 | 7 | private typedef Customer = { 8 | @:autoIncrement @:primary public var id(default, null):Id; 9 | public var avatar(default, null):VarChar<50>; 10 | } 11 | 12 | interface D extends tink.sql.DatabaseDefinition { 13 | @:table var fa_customer:Customer; 14 | } 15 | 16 | 17 | private class Db extends tink.sql.Database { 18 | public var user(get, never):tink.sql.Table<{fa_customer:Customer}>; 19 | 20 | inline function get_user() 21 | return fa_customer; 22 | } 23 | 24 | class TestIssue104 { 25 | public function new() {} 26 | public function testMain() { 27 | var driver2 = new tink.sql.drivers.Sqlite(db -> ':memory:'); 28 | var db = new Db('fa_klw', driver2); 29 | return db.user.create() 30 | .next(_ -> 31 | db.user.insertOne({ 32 | id: cast null, 33 | avatar: "TEST.PNG", 34 | }) 35 | ) 36 | .next(insertedId -> assert(insertedId == 1)); 37 | } 38 | } -------------------------------------------------------------------------------- /tests/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | mysql: 4 | image: mysql:5.7 5 | environment: 6 | - TZ=UTC 7 | - MYSQL_ALLOW_EMPTY_PASSWORD=1 8 | - MYSQL_DATABASE=test 9 | ports: 10 | - "3306:3306" 11 | healthcheck: 12 | test: ["CMD", "mysqladmin", "ping", "--silent"] 13 | postgres: 14 | image: postgis/postgis:13-3.1 15 | environment: 16 | - POSTGRES_DB=test 17 | - POSTGRES_USER=postgres 18 | - POSTGRES_PASSWORD=postgres 19 | ports: 20 | - "5432:5432" 21 | healthcheck: 22 | test: ["CMD", "pg_isready"] 23 | cockroachdb: 24 | # We use the unstable image here for easy config with env vars 25 | # https://github.com/cockroachdb/cockroach/commit/2862374a4743aece5707ea52d0ac17f6cb10dc7b 26 | image: cockroachdb/cockroach-unstable:v22.1.0-alpha.1 27 | environment: 28 | - COCKROACH_DATABASE=test 29 | - COCKROACH_USER=crdb 30 | - COCKROACH_PASSWORD=crdb 31 | ports: 32 | - "26257:26257" 33 | command: start-single-node --insecure 34 | -------------------------------------------------------------------------------- /tests/fixture/schema_identical.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS test.Schema; 2 | CREATE TABLE test.Schema ( 3 | `id` int(12) UNSIGNED NOT NULL, 4 | `a` tinyint(1) NOT NULL, 5 | `b` tinyint(1) NOT NULL, 6 | `c` tinyint(1) NOT NULL, 7 | `d` tinyint(1) NOT NULL, 8 | `e` tinyint(1) NOT NULL, 9 | `f` tinyint(1) NOT NULL, 10 | `g` tinyint(1) NOT NULL, 11 | `h` tinyint(1) NOT NULL, 12 | `indexed` tinyint(1) NOT NULL, 13 | `toAdd` tinyint(1) NOT NULL, 14 | `toBoolean` tinyint(1) NOT NULL, 15 | `toFloat` float NOT NULL, 16 | `toInt` int(11) NOT NULL, 17 | `toLongText` text NOT NULL, 18 | `toText` varchar(1) NOT NULL, 19 | `toDate` datetime NOT NULL, 20 | `unique` tinyint(1) NOT NULL 21 | ); 22 | 23 | ALTER TABLE test.Schema 24 | ADD PRIMARY KEY (`id`), 25 | ADD UNIQUE KEY `ef` (`e`,`f`), 26 | ADD UNIQUE KEY `gh` (`g`,`h`), 27 | ADD UNIQUE KEY `unique` (`unique`), 28 | ADD KEY `ab` (`a`,`b`), 29 | ADD KEY `cd` (`c`,`d`), 30 | ADD KEY `indexed` (`indexed`); 31 | 32 | ALTER TABLE test.Schema 33 | MODIFY `id` int(12) UNSIGNED NOT NULL AUTO_INCREMENT; -------------------------------------------------------------------------------- /src/tink/sql/Fields.macro.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | import tink.macro.BuildCache; 4 | import haxe.macro.Expr; 5 | 6 | using tink.MacroApi; 7 | 8 | class Fields { 9 | public static function build() { 10 | return BuildCache.getType('tink.sql.Fields', (ctx:BuildContext) -> { 11 | final name = ctx.name; 12 | final ct = ctx.type.toComplex(); 13 | switch ctx.type.reduce() { 14 | case TAnonymous(_.get().fields => fields): 15 | final def = macro class $name {} 16 | for(field in fields) { 17 | final fct = field.type.toComplex(); 18 | def.fields.push({ 19 | pos: field.pos, 20 | name: field.name, 21 | kind: FProp('default', 'never', macro : tink.sql.Expr.Field<$fct, tink.sql.Results<$ct>>) 22 | }); 23 | } 24 | 25 | def.kind = TDStructure; 26 | def.pack = ['tink', 'sql']; 27 | def; 28 | case v: 29 | ctx.pos.error('[tink.sql.Fields] Expected anonymous structure, but got $v'); 30 | } 31 | }); 32 | } 33 | } -------------------------------------------------------------------------------- /tests/fixture/schema_indexes.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS test.Schema; 2 | CREATE TABLE test.Schema ( 3 | `id` int(12) UNSIGNED NOT NULL, 4 | `a` tinyint(1) NOT NULL, 5 | `b` tinyint(1) NOT NULL, 6 | `c` tinyint(1) NOT NULL, 7 | `d` tinyint(1) NOT NULL, 8 | `e` tinyint(1) NOT NULL, 9 | `f` tinyint(1) NOT NULL, 10 | `g` tinyint(1) NOT NULL, 11 | `h` tinyint(1) NOT NULL, 12 | `indexed` tinyint(1) NOT NULL, 13 | `toAdd` tinyint(1) NOT NULL, 14 | `toBoolean` tinyint(1) NOT NULL, 15 | `toFloat` float NOT NULL, 16 | `toInt` int(11) UNSIGNED NOT NULL, 17 | `toLongText` text NOT NULL, 18 | `toText` varchar(1) NOT NULL, 19 | `toDate` datetime NOT NULL, 20 | `unique` tinyint(1) NOT NULL 21 | ); 22 | 23 | ALTER TABLE test.Schema 24 | ADD KEY `ab` (`a`), -- Add b 25 | ADD UNIQUE KEY `ef` (`f`), -- Add e 26 | ADD UNIQUE KEY `h` (`h`), -- Remove 27 | ADD PRIMARY KEY (`id`), 28 | ADD UNIQUE KEY `unique` (`b`), -- Named after another field 29 | ADD UNIQUE KEY `indexed` (`indexed`); -- Unique to index 30 | 31 | ALTER TABLE test.Schema 32 | MODIFY `id` int(12) UNSIGNED NOT NULL AUTO_INCREMENT; -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | workspace: 4 | image: ghcr.io/haxetink/tink_sql_devcontainer:master 5 | init: true 6 | volumes: 7 | - /var/run/docker.sock:/var/run/docker-host.sock 8 | - ..:/workspace:cached 9 | environment: 10 | - EARTHLY_BUILDKIT_HOST=tcp://earthly:8372 11 | - EARTHLY_USE_INLINE_CACHE=true 12 | - EARTHLY_SAVE_INLINE_CACHE=true 13 | user: vscode 14 | entrypoint: /usr/local/share/docker-init.sh 15 | command: sleep infinity 16 | # Allow using the DBs running on host for testing 17 | # e.g. MYSQL_HOST=host.docker.internal POSTGRES_HOST=host.docker.internal npm test node 18 | extra_hosts: 19 | - "host.docker.internal:host-gateway" 20 | earthly: 21 | image: earthly/buildkitd:v0.6.14 22 | privileged: true 23 | environment: 24 | - BUILDKIT_TCP_TRANSPORT_ENABLED=true 25 | expose: 26 | - 8372 27 | volumes: 28 | # https://docs.earthly.dev/docs/guides/using-the-earthly-docker-images/buildkit-standalone#earthly_tmp_dir 29 | - earthly-tmp:/tmp/earthly:rw 30 | 31 | volumes: 32 | earthly-tmp: 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013 Juraj Kirchheim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/tink/sql/schema/KeyStore.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.schema; 2 | 3 | import tink.sql.Info; 4 | 5 | class KeyStore { 6 | var primaryFields = []; 7 | var namedKeys = new Map(); 8 | 9 | public function new() {} 10 | 11 | public function addPrimary(field:String) 12 | primaryFields.push(field); 13 | 14 | public function addUnique(name:String, field:String) 15 | if (namedKeys.exists(name)) 16 | switch namedKeys[name] { 17 | case Unique(_, fields): fields.push(field); 18 | default: throw 'Key "$name" is of different type'; 19 | } 20 | else namedKeys.set(name, Unique(name, [field])); 21 | 22 | public function addIndex(name:String, field:String) 23 | if (namedKeys.exists(name)) 24 | switch namedKeys[name] { 25 | case Index(_, fields): fields.push(field); 26 | default: throw 'Key "$name" is of different type'; 27 | } 28 | else namedKeys.set(name, Index(name, [field])); 29 | 30 | public function get():Array 31 | return ( 32 | if (primaryFields.length > 0) [Primary(primaryFields)] 33 | else [] 34 | ).concat(Lambda.array(namedKeys)); 35 | 36 | } -------------------------------------------------------------------------------- /src/tink/sql/DatabaseInfo.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | import tink.sql.Info; 4 | 5 | using tink.CoreApi; 6 | 7 | class DatabaseStaticInfo implements DatabaseInfo { 8 | 9 | final tables:Map; 10 | 11 | public function new(tables) { 12 | this.tables = tables; 13 | } 14 | 15 | public function instantiate(name) { 16 | return new DatabaseInstanceInfo(name, tables); 17 | } 18 | 19 | public function tableNames():Iterable 20 | return { 21 | iterator: function () return tables.keys() 22 | }; 23 | 24 | public function tableInfo(name:String):TableInfo 25 | return switch tables[name] { 26 | case null: throw new Error(NotFound, 'Table `${nameOfTable(name)}` not found'); 27 | case v: cast v; 28 | } 29 | 30 | function nameOfTable(tbl:String) { 31 | return tbl; 32 | } 33 | } 34 | 35 | class DatabaseInstanceInfo extends DatabaseStaticInfo { 36 | 37 | final name:String; 38 | 39 | public function new(name, tables) { 40 | super(tables); 41 | this.name = name; 42 | } 43 | 44 | override function nameOfTable(tbl:String) { 45 | return '$name.$tbl'; 46 | } 47 | } -------------------------------------------------------------------------------- /src/tink/sql/drivers/sys/Sqlite.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.drivers.sys; 2 | 3 | import tink.sql.format.SqliteFormatter; 4 | import tink.sql.format.Sanitizer; 5 | import haxe.io.Bytes; 6 | import tink.sql.Info; 7 | 8 | private class SqliteSanitizer implements Sanitizer { 9 | 10 | var cnx:sys.db.Connection; 11 | 12 | public function new(cnx) 13 | this.cnx = cnx; 14 | 15 | public function value(v:Any):String { 16 | if (Std.is(v, Bool)) return v ? '1' : '0'; 17 | if (v == null || Std.is(v, Int)) return '$v'; 18 | if (Std.is(v, Bytes)) v = (cast v: Bytes).toString(); 19 | return cnx.quote('$v'); 20 | } 21 | 22 | public function ident(s:String):String 23 | return '`'+cnx.escape(s)+'`'; 24 | 25 | } 26 | 27 | class Sqlite implements Driver { 28 | public final type:Driver.DriverType = Sqlite; 29 | 30 | var fileForName: String->String; 31 | 32 | public function new(?fileForName:String->String) 33 | this.fileForName = fileForName; 34 | 35 | public function open(name:String, info:Db):Connection { 36 | var cnx = sys.db.Sqlite.open( 37 | switch fileForName { 38 | case null: name; 39 | case f: f(name); 40 | } 41 | ); 42 | return new StdConnection(info, cnx, new SqliteFormatter(), new SqliteSanitizer(cnx)); 43 | } 44 | 45 | } -------------------------------------------------------------------------------- /src/tink/sql/drivers/sys/MySql.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.drivers.sys; 2 | 3 | import tink.sql.Driver; 4 | import tink.sql.format.MySqlFormatter; 5 | import tink.sql.Info; 6 | 7 | class MySql implements Driver { 8 | 9 | public final type:Driver.DriverType = MySql; 10 | 11 | var settings:MySqlSettings; 12 | 13 | public function new(settings) 14 | this.settings = settings; 15 | 16 | public function open(name:String, info:Db):Connection { 17 | check(); 18 | var cnx = sys.db.Mysql.connect({ 19 | host: switch settings.host { 20 | case null: 'localhost'; 21 | case v: v; 22 | }, 23 | user: settings.user, 24 | pass: settings.password, 25 | database: name, 26 | }); 27 | return new StdConnection( 28 | info, 29 | cnx, 30 | new MySqlFormatter(), 31 | tink.sql.drivers.MySql.getSanitizer(null) 32 | ); 33 | } 34 | 35 | macro static function check() { 36 | #if java 37 | try { 38 | haxe.macro.Context.getType('com.mysql.jdbc.jdbc2.optional.MysqlDataSource'); 39 | } 40 | catch (e:Dynamic) { 41 | haxe.macro.Context.error('It seems your build does not include a mysql driver. Consider using `-lib jdbc.mysql`', haxe.macro.Context.currentPos()); 42 | } 43 | #end 44 | return macro null; 45 | } 46 | } -------------------------------------------------------------------------------- /src/tink/sql/Transaction.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | import tink.sql.Info; 4 | 5 | using tink.CoreApi; 6 | 7 | enum TransactionEnd { 8 | Commit(result:T); 9 | Rollback; 10 | } 11 | 12 | @:genericBuild(tink.sql.Transaction.build()) 13 | class Transaction {} 14 | 15 | class TransactionTools { 16 | public static function transaction(pool:Connection.ConnectionPool, run:Connection->Promise>):Promise> { 17 | return switch pool.isolate() { 18 | case {a: isolated, b: lock}: 19 | isolated.execute(Transaction(Start)) 20 | .next(function (_) 21 | return run(isolated) 22 | .flatMap(function (result) 23 | return isolated.execute(Transaction(switch result { 24 | case Success(Commit(_)): Commit; 25 | case Success(Rollback) | Failure(_): Rollback; 26 | })).next(function (_) { 27 | lock.cancel(); 28 | return result; 29 | }) 30 | ) 31 | ); 32 | } 33 | } 34 | } 35 | 36 | 37 | class TransactionObject { 38 | 39 | // To type this correctly we'd need a self type #4474 or unnecessary macros 40 | final __cnx:Connection; 41 | 42 | function new(cnx) { 43 | __cnx = cnx; 44 | } 45 | 46 | macro public function from(ethis, target); 47 | } -------------------------------------------------------------------------------- /tests/TransactionTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import tink.unit.Assert.assert; 4 | import tink.sql.Transaction; 5 | 6 | using tink.CoreApi; 7 | 8 | @:asserts 9 | class TransactionTest extends TestWithDb { 10 | 11 | @:before 12 | public function createTable() return db.User.create(); 13 | 14 | @:after 15 | public function dropTable() return db.User.drop(); 16 | 17 | public function commit() { 18 | return db.transaction(trx -> { 19 | trx.User.insertOne({ 20 | id: cast null, 21 | name: '', email: '', location: '' 22 | }).next(id -> Commit(id)); 23 | }) 24 | .next(res -> assert(res.equals(Commit(1)))) 25 | .next(_ -> db.User.all()) 26 | .next(res -> assert(res.length == 1)); 27 | } 28 | 29 | public function rollback() { 30 | return db.transaction(trx -> { 31 | trx.User.insertOne({ 32 | id: cast null, 33 | name: '', email: '', location: '' 34 | }).next(_ -> Rollback); 35 | }) 36 | .next(_ -> db.User.all()) 37 | .next(res -> assert(res.length == 0)); 38 | } 39 | 40 | public function aborted() { 41 | return db.transaction(trx -> { 42 | trx.User.insertOne({ 43 | id: cast null, 44 | name: '', email: '', location: '' 45 | }).next(_ -> new Error('Aborted')); 46 | }) 47 | .flatMap(_ -> db.User.all()).asPromise() 48 | .next(res -> assert(res.length == 0)); 49 | } 50 | 51 | } -------------------------------------------------------------------------------- /tests/ProcedureTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import Run.loadFixture; 4 | 5 | @:asserts 6 | class ProcedureTest extends TestWithDb { 7 | 8 | public function test() { 9 | return loadFixture(db, 'procedure') 10 | .next(_ -> { 11 | db.func.call(1).all().handle(function(o) switch o { 12 | case Success(result): 13 | asserts.assert(result.length == 2); 14 | asserts.assert(result[0].x == 1); 15 | asserts.assert(result[1].x == 2); 16 | asserts.assert(result[0].point.latitude == 1.0); 17 | asserts.assert(result[0].point.longitude == 2.0); 18 | asserts.assert(result[1].point.latitude == 2.0); 19 | asserts.assert(result[1].point.longitude == 3.0); 20 | asserts.done(); 21 | case Failure(e): 22 | asserts.fail(e); 23 | }); 24 | asserts; 25 | }); 26 | } 27 | 28 | public function limit() { 29 | return loadFixture(db, 'procedure') 30 | .next(_ -> { 31 | db.func.call(1).first().handle(function(o) switch o { 32 | case Success(result): 33 | asserts.assert(result.x == 1); 34 | asserts.assert(result.point.latitude == 1.0); 35 | asserts.assert(result.point.longitude == 2.0); 36 | asserts.done(); 37 | case Failure(e): 38 | asserts.fail(e); 39 | }); 40 | asserts; 41 | }); 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /tests/ConnectionTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import tink.unit.Assert.assert; 4 | import tink.sql.OrderBy; 5 | import Db; 6 | import tink.sql.Fields; 7 | import tink.sql.expr.Functions; 8 | 9 | using tink.CoreApi; 10 | 11 | @:asserts 12 | class ConnectionTest extends TestWithDb { 13 | 14 | @:setup 15 | public function setup() { 16 | return db.User.create(); 17 | } 18 | 19 | @:teardown 20 | public function teardown() { 21 | return db.User.drop(); 22 | } 23 | 24 | #if nodejs // this test is only useful for async runtimes 25 | public function release() { 26 | Promise.inParallel([addUser(0).next(_ -> new Error('Halt'))].concat([for(i in 1...10) addUser(i)])) 27 | .flatMap(o -> { 28 | switch o { 29 | case Success(v): asserts.fail('Expected Failure'); 30 | case Failure(e): asserts.assert(e.message == 'Halt'); 31 | } 32 | db.User.count(); // make sure connections are released properly so this query can run 33 | }) 34 | .asPromise() 35 | .next(count -> asserts.assert(count == 1)) // with pool size = 1, after the first user being added and produced error, subsequent inserts will not happen 36 | .handle(asserts.handle); 37 | 38 | return asserts; 39 | } 40 | #end 41 | 42 | function addUser(i:Int) { 43 | return db.User.insertOne({ 44 | id: null, 45 | name: 'user-$i', 46 | location: 'hk', 47 | email: 'email$i@email.com', 48 | }); 49 | } 50 | } -------------------------------------------------------------------------------- /src/tink/sql/Info.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | import haxe.Int64; 4 | 5 | interface DatabaseInfo { 6 | function tableNames():Iterable; 7 | function tableInfo(name:String):TableInfo; 8 | } 9 | 10 | interface TableInfo { 11 | function getName():String; 12 | function getAlias():Null; 13 | function getColumns():Iterable; 14 | function columnNames():Iterable; 15 | function getKeys():Iterable; 16 | } 17 | 18 | typedef Column = { 19 | name:String, 20 | nullable:Bool, 21 | type:DataType, 22 | writable:Bool, 23 | } 24 | 25 | enum Key { 26 | Primary(fields:Array); 27 | Unique(name:String, fields:Array); 28 | Index(name:String, fields:Array); 29 | } 30 | 31 | enum DataType { 32 | DBool(?byDefault:Bool); 33 | DInt(size:IntSize, signed:Bool, autoIncrement:Bool, ?byDefault:Dynamic); 34 | DDouble(?byDefault:Float); 35 | DString(maxLength:Int, ?byDefault:String); 36 | DText(size:TextSize, ?byDefault:String); 37 | DJson; 38 | DBlob(maxLength:Int); 39 | DDate(?byDefault:Date); 40 | DDateTime(?byDefault:Date); 41 | DTimestamp(?byDefault:Date); 42 | DPoint; 43 | DLineString; 44 | DPolygon; 45 | DMultiPoint; 46 | DMultiLineString; 47 | DMultiPolygon; 48 | DUnknown(type:String, byDefault:Null); 49 | } 50 | 51 | enum IntSize { 52 | Tiny; 53 | Small; 54 | Medium; 55 | Default; 56 | Big; 57 | } 58 | 59 | enum TextSize { 60 | Tiny; 61 | Default; 62 | Medium; 63 | Long; 64 | } -------------------------------------------------------------------------------- /src/tink/sql/Results.macro.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | import tink.macro.BuildCache; 4 | import haxe.macro.Expr; 5 | 6 | using tink.MacroApi; 7 | 8 | class Results { 9 | public static function build() { 10 | return BuildCache.getType('tink.sql.Results', (ctx:BuildContext) -> { 11 | final name = ctx.name; 12 | switch ctx.type.reduce() { 13 | case TAnonymous(_.get().fields => fields): 14 | final def = macro class $name {} 15 | for(field in fields) { 16 | final fct = field.type.toComplex(); 17 | def.fields.push({ 18 | pos: field.pos, 19 | name: field.name, 20 | #if haxe4 21 | access: if (field.isFinal) [AFinal] else [], 22 | kind: 23 | if (field.isFinal) FVar(fct) 24 | else FProp('default', 'never', fct), 25 | #else 26 | kind: FProp('default', 'never', fct), 27 | #end 28 | meta: { 29 | var m = []; 30 | if(field.meta.extract(':optional').length > 0) 31 | m.push({name: ':optional', pos: field.pos}); 32 | m; 33 | }, 34 | }); 35 | } 36 | 37 | def.kind = TDStructure; 38 | def.pack = ['tink', 'sql']; 39 | def; 40 | case v: 41 | ctx.pos.error('[tink.sql.Results] Expected anonymous structure, but got $v'); 42 | } 43 | }); 44 | } 45 | } -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | services: 9 | mysql: 10 | image: mysql:5.7 11 | ports: 12 | - 3306:3306 13 | env: 14 | MYSQL_DATABASE: test 15 | MYSQL_ALLOW_EMPTY_PASSWORD: yes 16 | options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 17 | postgres: 18 | image: postgis/postgis:13-3.1 19 | ports: 20 | - 5432:5432 21 | env: 22 | POSTGRES_DB: test 23 | POSTGRES_USER: postgres 24 | POSTGRES_PASSWORD: postgres 25 | options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3 26 | strategy: 27 | matrix: 28 | haxe-version: 29 | - 4.0.5 30 | - 4.1.5 31 | - stable 32 | - nightly 33 | target: 34 | - node 35 | - php 36 | env: 37 | # excluded CockroachDb here because it is hard to create a GitHub Actions service with command/entrypoint override 38 | TEST_DB_TYPES: MySql,PostgreSql,Sqlite 39 | steps: 40 | - uses: actions/checkout@v2 41 | - uses: actions/setup-node@v1 42 | - uses: shivammathur/setup-php@v2 43 | with: 44 | php-version: '7.4' 45 | if: ${{ matrix.target == 'php' }} 46 | - run: php --version 47 | if: ${{ matrix.target == 'php' }} 48 | - run: npm i 49 | - run: npm run test ${{ matrix.target }} -------------------------------------------------------------------------------- /src/tink/sql/format/SqliteFormatter.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.format; 2 | 3 | import tink.sql.Info; 4 | import tink.sql.Query; 5 | import tink.sql.schema.KeyStore; 6 | import tink.sql.Expr; 7 | import tink.sql.format.SqlFormatter; 8 | import tink.sql.format.Statement.StatementFactory.*; 9 | 10 | class SqliteFormatter extends SqlFormatter<{}, {}> { 11 | 12 | override public function format(query:Query):Statement 13 | return switch query { 14 | default: super.format(query); 15 | } 16 | 17 | override public function defineColumn(column:Column):Statement { 18 | var autoIncrement = column.type.match(DInt(_, _, true)); 19 | return ident(column.name).add( 20 | if (autoIncrement) 'INTEGER' 21 | else type(column.type).add(nullable(column.nullable)) 22 | ); 23 | } 24 | 25 | override function keyType(key:Key):Statement 26 | return switch key { 27 | case Primary(_): sql('PRIMARY KEY'); 28 | case Unique(name, [field]) if(name == field): sql('UNIQUE'); 29 | case Unique(name, _): sql('CONSTRAINT').addIdent(name).add(sql('UNIQUE')); 30 | case Index(name, _): sql('INDEX').addIdent(name); 31 | } 32 | 33 | override function type(type: DataType):Statement 34 | return switch type { 35 | case DText(size, d): 36 | sql('TEXT').add(addDefault(d)); 37 | default: super.type(type); 38 | } 39 | 40 | override function union(union:UnionOperation) 41 | return format(union.left) 42 | .add('UNION') 43 | .add('ALL', !union.distinct) 44 | .add(format(union.right)) 45 | .add(limit(union.limit)); 46 | 47 | override function beginTransaction() 48 | return 'BEGIN TRANSACTION'; 49 | } 50 | -------------------------------------------------------------------------------- /src/tink/sql/Database.macro.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | import haxe.macro.Expr; 4 | import haxe.macro.Context; 5 | import tink.macro.BuildCache; 6 | import tink.sql.macros.Helper; 7 | 8 | using tink.MacroApi; 9 | 10 | class Database { 11 | public static function build() { 12 | return BuildCache.getType('tink.sql.Database', (ctx:BuildContext) -> { 13 | final name = ctx.name; 14 | final ct = ctx.type.toComplex(); 15 | final def = macro class $name extends tink.sql.Transaction<$ct> { 16 | public static final INFO = ${Helper.typePathToExpr(switch macro:tink.sql.Transaction<$ct> {case TPath(tp): tp; case _: null;}, ctx.pos)}.INFO; 17 | 18 | public final __name:String; 19 | public final __info:tink.sql.Info.DatabaseInfo; 20 | public final __pool:tink.sql.Connection.ConnectionPool<$ct>; 21 | public final __driver:tink.sql.Driver; 22 | 23 | public function new(name, driver:tink.sql.Driver) { 24 | super(__pool = (__driver = driver).open(__name = name, __info = INFO.instantiate(name))); 25 | } 26 | 27 | public inline function getName() return __name; 28 | public inline function getInfo() return __info; 29 | 30 | public function transaction(run:tink.sql.Transaction<$ct>->tink.core.Promise>):tink.core.Promise> { 31 | return tink.sql.Transaction.TransactionTools.transaction(__pool, isolated -> run(new tink.sql.Transaction<$ct>(isolated))); 32 | } 33 | } 34 | 35 | // trace(new haxe.macro.Printer().printTypeDefinition(def)); 36 | def.pack = ['tink', 'sql']; 37 | def; 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/tink/sql/DatabaseDefinition.macro.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | import haxe.macro.Expr; 4 | import haxe.macro.Context; 5 | import tink.macro.ClassBuilder; 6 | 7 | using tink.MacroApi; 8 | 9 | class DatabaseDefinition { 10 | public static function build() { 11 | final c = new ClassBuilder(); 12 | 13 | for (t in c.target.meta.extract(':tables')) 14 | for (p in t.params) { 15 | var tp = p.toString().asTypePath(); 16 | c.addMember({ 17 | pos: p.pos, 18 | name: switch tp { case { sub: null, name: name } | { sub: name } : name; }, 19 | meta: [{ name: ':table', pos: p.pos, params: [] }], 20 | kind: FVar(TPath(tp)), 21 | }); 22 | } 23 | 24 | for (m in c) if(!m.isStatic) { 25 | function extractMeta(name:String) { 26 | return switch (m:Field).meta.getValues(name) { 27 | case []: null; 28 | case [[]]: m.name; 29 | case [[v]]: v.getName().sure(); 30 | default: m.pos.error('Invalid use of @$name'); 31 | } 32 | } 33 | 34 | switch extractMeta(':table') { 35 | case null: 36 | case table: 37 | var type = TAnonymous([{ 38 | name : m.name, 39 | pos: m.pos, 40 | kind: FVar(m.getVar().sure().type), 41 | }]); 42 | m.kind = FVar(macro : tink.sql.Table<$type>); 43 | m.isFinal = true; 44 | } 45 | 46 | switch extractMeta(':procedure') { 47 | case null: 48 | case procedure: 49 | final type = m.getVar().sure().type; 50 | m.kind = FVar(macro : tink.sql.Procedure<$type>); 51 | m.isFinal = true; 52 | } 53 | } 54 | 55 | return c.export(); 56 | } 57 | } -------------------------------------------------------------------------------- /src/tink/sql/macros/ProcedureBuilder.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.macros; 2 | 3 | import haxe.macro.Context; 4 | import tink.macro.BuildCache; 5 | import haxe.macro.Expr; 6 | import tink.sql.schema.KeyStore; 7 | 8 | using tink.MacroApi; 9 | 10 | class ProcedureBuilder { 11 | static function build() { 12 | return BuildCache.getType('tink.sql.Procedure', function (ctx:BuildContext) { 13 | return 14 | switch ctx.type { 15 | case TFun(args, _.toComplex() => ret): 16 | var cName = ctx.name; 17 | var def = macro class $cName extends tink.sql.Procedure.ProcedureBase {} 18 | 19 | var i = 0; 20 | var args = [for(arg in args) { 21 | var name = switch arg.name { 22 | case null | '': '__a' + i++; 23 | case v: v; 24 | }; 25 | var t = arg.t.toComplex(); 26 | name.toArg(macro:tink.sql.Expr<$t>); 27 | }]; 28 | 29 | def.fields.push({ 30 | name: 'call', 31 | access: [APublic], 32 | kind: FFun({ 33 | args: args, 34 | ret: macro:tink.sql.Procedure.Called<$ret, $ret, Db>, 35 | expr: { 36 | var args = [for(arg in args) macro $i{arg.name}]; 37 | macro return { 38 | var args:Array> = $a{args} 39 | return new tink.sql.Procedure.Called<$ret, $ret, Db>(this.cnx, this.name, args); 40 | } 41 | }, 42 | }), 43 | pos: ctx.pos, 44 | }); 45 | 46 | def; 47 | default: 48 | ctx.pos.error('invalid usage of Prodcedure'); 49 | } 50 | }); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /tests/StringTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import Db; 4 | import tink.sql.Info; 5 | import tink.unit.Assert.assert; 6 | 7 | using tink.CoreApi; 8 | 9 | @:asserts 10 | @:allow(tink.unit) 11 | class StringTest extends TestWithDb { 12 | 13 | @:before 14 | public function createTable() { 15 | return db.StringTypes.create(); 16 | } 17 | 18 | @:after 19 | public function dropTable() { 20 | return db.StringTypes.drop(); 21 | } 22 | 23 | public function insert() { 24 | var mydate = new Date(2000, 0, 1, 0, 0, 0); 25 | function generateString(length) { 26 | return StringTools.lpad("", ".", length); 27 | } 28 | function desc(length: Int) 29 | return 'compare strings of length $length'; 30 | var future = db.StringTypes.insertOne({ 31 | id: null, 32 | text10: generateString(10), 33 | text255: generateString(255), 34 | text999: generateString(999), 35 | // Note: even though the type is VarChar<65536> it is a Text column, so max length 65535 36 | text65536: generateString(65535), 37 | textTiny: generateString(255), 38 | textDefault: generateString(65535), 39 | textMedium: generateString(70000), 40 | textLong: generateString(80000), 41 | }) 42 | .next(function(id:Int) return db.StringTypes.first()) 43 | .next(function(row:StringTypes) { 44 | asserts.assert(row.text10 == generateString(10), desc(10)); 45 | asserts.assert(row.text255 == generateString(255), desc(255)); 46 | asserts.assert(row.text999 == generateString(999), desc(999)); 47 | asserts.assert(row.text65536 == generateString(65535), desc(65535)); 48 | asserts.assert(row.textTiny == generateString(255), desc(255)); 49 | asserts.assert(row.textDefault == generateString(65535), desc(65535)); 50 | asserts.assert(row.textMedium == generateString(70000), desc(70000)); 51 | asserts.assert(row.textLong == generateString(80000), desc(80000)); 52 | 53 | return Noise; 54 | }); 55 | 56 | future.handle(function(o) switch o { 57 | case Success(_): asserts.done(); 58 | case Failure(e): asserts.fail(e, e.pos); 59 | }); 60 | 61 | return asserts; 62 | } 63 | } -------------------------------------------------------------------------------- /tests/BigIntTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import Db; 4 | import tink.sql.Info; 5 | import tink.unit.Assert.assert; 6 | import haxe.Int64; 7 | 8 | using tink.CoreApi; 9 | 10 | @:asserts 11 | @:allow(tink.unit) 12 | class BigIntTest extends TestWithDb { 13 | @:before 14 | public function createTable() { 15 | return db.BigIntTypes.create(); 16 | } 17 | 18 | @:after 19 | public function dropTable() { 20 | return db.BigIntTypes.drop(); 21 | } 22 | 23 | static final int64Min = haxe.Int64.parseString('-9223372036854775808'); 24 | static final int64Max = haxe.Int64.parseString('9223372036854775807'); 25 | 26 | public function insert() { 27 | function desc(length:Int) 28 | return 'compare strings of length $length'; 29 | final future = db.BigIntTypes.insertOne({ 30 | id: null, 31 | int0: 0, 32 | intMin: int64Min, 33 | intMax: int64Max, 34 | }) 35 | .next(function(id:tink.sql.Types.Id64) return db.BigIntTypes.where(r -> r.id == id).first()) 36 | .next(function(row:BigIntTypes) { 37 | asserts.assert(row.int0 == 0); 38 | asserts.assert(row.intMin == int64Min); 39 | asserts.assert(row.intMax == int64Max); 40 | return Noise; 41 | }); 42 | 43 | future.handle(function(o) switch o { 44 | case Success(_): 45 | asserts.done(); 46 | case Failure(e): 47 | asserts.fail(e, e.pos); 48 | }); 49 | 50 | return asserts; 51 | } 52 | 53 | public function select() { 54 | final future = db.BigIntTypes.insertOne({ 55 | id: null, 56 | int0: 0, 57 | intMin: int64Min, 58 | intMax: int64Max, 59 | }) 60 | .next(_ -> db.BigIntTypes.where(r -> r.intMax == int64Max).count()) 61 | .next(count -> { 62 | asserts.assert(count == 1); 63 | }) 64 | .next(_ -> db.BigIntTypes.where(r -> r.intMax == r.intMax).count()) 65 | .next(count -> { 66 | asserts.assert(count == 1); 67 | }) 68 | .next(_ -> Noise); 69 | 70 | future.handle(function(o) switch o { 71 | case Success(_): 72 | asserts.done(); 73 | case Failure(e): 74 | asserts.fail(e, e.pos); 75 | }); 76 | 77 | return asserts; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tests/TypeTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import Db; 4 | 5 | using tink.CoreApi; 6 | 7 | @:asserts 8 | @:allow(tink.unit) 9 | class TypeTest extends TestWithDb { 10 | 11 | @:before 12 | public function createTable() { 13 | return db.Types.create(); 14 | } 15 | 16 | @:after 17 | public function dropTable() { 18 | return db.Types.drop(); 19 | } 20 | 21 | @:variant(new Date(2000, 0, 1, 0, 0, 0)) 22 | @:variant(new Date(1920, 0, 1, 0, 0, 0)) 23 | public function insert(mydate:Date) { 24 | var future = db.Types.insertOne({ 25 | int: 123, 26 | float: 1.23, 27 | text: 'mytext', 28 | blob: haxe.io.Bytes.ofString('myblob'), 29 | varbinary: haxe.io.Bytes.ofString('myvarbinary'), 30 | date: mydate, 31 | boolTrue: true, 32 | boolFalse: false, 33 | 34 | nullInt: null, 35 | nullText: null, 36 | nullBlob: null, 37 | nullVarbinary: null, 38 | nullDate: null, 39 | nullBool: null, 40 | }) 41 | .next(function(id:Int) return db.Types.first()) 42 | .next(function(row:Types) { 43 | asserts.assert(row.int == 123); 44 | asserts.assert(row.text == 'mytext'); 45 | asserts.assert(row.date.getTime() == mydate.getTime()); 46 | asserts.assert(row.blob.toHex() == '6d79626c6f62'); 47 | asserts.assert(row.varbinary.toHex() == haxe.io.Bytes.ofString('myvarbinary').toHex()); 48 | asserts.assert(row.boolTrue == true); 49 | asserts.assert(row.boolFalse == false); 50 | 51 | asserts.assert(row.optionalInt == null); 52 | asserts.assert(row.optionalText == null); 53 | asserts.assert(row.optionalDate == null); 54 | asserts.assert(row.optionalBlob == null); 55 | asserts.assert(row.optionalVarbinary == null); 56 | asserts.assert(row.optionalBool == null); 57 | 58 | asserts.assert(row.nullInt == null); 59 | asserts.assert(row.nullText == null); 60 | asserts.assert(row.nullDate == null); 61 | asserts.assert(row.nullBlob == null); 62 | asserts.assert(row.nullVarbinary == null); 63 | asserts.assert(row.nullBool == null); 64 | 65 | return Noise; 66 | }); 67 | 68 | future.handle(function(o) switch o { 69 | case Success(_): asserts.done(); 70 | case Failure(e): asserts.fail(e); 71 | }); 72 | 73 | return asserts; 74 | } 75 | } -------------------------------------------------------------------------------- /src/tink/sql/Transaction.macro.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | import haxe.macro.Expr; 4 | import haxe.macro.Context; 5 | import tink.macro.BuildCache; 6 | import tink.sql.macros.Helper; 7 | 8 | using tink.MacroApi; 9 | 10 | class Transaction { 11 | public static function build() { 12 | return BuildCache.getType('tink.sql.Transaction', (ctx:BuildContext) -> { 13 | final name = ctx.name; 14 | final ct = ctx.type.toComplex(); 15 | final path = switch ct { case TPath(path): path; case _: throw 'assert';} 16 | final tableInfos = []; 17 | final init = []; 18 | final def = macro class $name extends tink.sql.Transaction.TransactionObject<$ct> implements $path { 19 | public static final INFO = new tink.sql.DatabaseInfo.DatabaseStaticInfo(${macro $a{tableInfos}}); 20 | 21 | public function new(cnx:tink.sql.Connection<$ct>) { 22 | super(cnx); 23 | $b{init} 24 | } 25 | } 26 | 27 | for(f in tink.sql.macros.Helper.getDatabaseFields(ctx.type, ctx.pos)) { 28 | final fname = f.name; 29 | 30 | switch f.kind { 31 | case DFTable(name, type): // `name` is the actual table name as seen by the database 32 | final ct = type.toComplex({direct: true}); 33 | final path = switch ct { case TPath(path): path; case _: throw 'assert';} 34 | def.fields = def.fields.concat((macro class { 35 | public final $fname:$ct; 36 | }).fields); 37 | 38 | init.push(macro @:pos(f.pos) this.$fname.init(cnx, $v{name}, $v{fname})); 39 | tableInfos.push(macro @:pos(f.pos) $v{name} => @:privateAccess ${tink.sql.macros.Helper.typePathToExpr(path, f.pos)}.makeInfo($v{name}, null)); 40 | 41 | case DFProcedure(name, type): 42 | final ct = type.toComplex({direct: true}); 43 | final path = switch ct { case TPath(path): path; case _: throw 'assert';} 44 | def.fields = def.fields.concat((macro class { 45 | public final $fname:$ct; 46 | }).fields); 47 | 48 | init.push(macro @:pos(f.pos) this.$fname = new $path(cnx, $v{name})); 49 | } 50 | } 51 | 52 | // trace(new haxe.macro.Printer().printTypeDefinition(def)); 53 | def.pack = ['tink', 'sql']; 54 | def; 55 | }); 56 | } 57 | } 58 | 59 | class TransactionObject { 60 | macro public function from(ethis:Expr, target:Expr) { 61 | return tink.sql.macros.Targets.from(ethis, target, macro $ethis.__cnx); 62 | } 63 | } -------------------------------------------------------------------------------- /tests/InsertIgnoreTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import tink.unit.Assert.assert; 4 | import tink.sql.OrderBy; 5 | import Db; 6 | import tink.sql.Fields; 7 | import tink.sql.expr.Functions; 8 | import tink.sql.Types; 9 | 10 | using tink.CoreApi; 11 | 12 | @:asserts 13 | class InsertIgnoreTest extends TestWithDb { 14 | var post:Id; 15 | 16 | @:setup @:access(Run) 17 | public function setup() { 18 | var run = new Run(driver, db); 19 | return Promise.inParallel([ 20 | db.User.create(), 21 | db.Post.create(), 22 | db.Clap.create(), 23 | ]) 24 | .next(function (_) return run.insertUsers()) 25 | .next(function (_) return db.User.where(r -> r.name == "Bob").first()) 26 | .next(function (bob) return db.Post.insertOne({ 27 | id: null, 28 | title: "Bob's post", 29 | author: bob.id, 30 | content: 'A wonderful post by Bob', 31 | })) 32 | .next(function (post) return this.post = post); 33 | } 34 | 35 | @:teardown 36 | public function teardown() { 37 | return Promise.inParallel([ 38 | db.User.drop(), 39 | db.Post.drop(), 40 | db.Clap.drop(), 41 | ]); 42 | } 43 | 44 | public function insert() 45 | return db.User.where(r -> r.name == "Alice") 46 | .first() 47 | .next(currentAlice -> { 48 | db.User.insertOne({ 49 | id: currentAlice.id, 50 | name: "Alice 2", 51 | email: currentAlice.email, 52 | location: currentAlice.location 53 | }, { 54 | ignore: true, 55 | }).next(_ -> { 56 | db.User.where(r -> r.name == "Alice").all(); 57 | }).next(alices -> { 58 | asserts.assert(alices.length == 1); 59 | asserts.assert(alices[0].id == currentAlice.id); 60 | asserts.assert(alices[0].name == "Alice"); 61 | asserts.done(); 62 | }); 63 | }); 64 | 65 | public function compositePrimaryKey() { 66 | return db.User.where(r -> r.name == "Christa").first() 67 | .next(christa -> 68 | db.Clap.insertOne({ 69 | user: christa.id, 70 | post: post, 71 | count: 1, 72 | }) 73 | .next(_ -> 74 | db.Clap 75 | .where(r -> r.user == christa.id && r.post == post) 76 | .first() 77 | ) 78 | .next(clap -> asserts.assert(clap.count == 1)) 79 | .next(_ -> 80 | db.Clap.insertOne({ 81 | user: christa.id, 82 | post: post, 83 | count: 1, 84 | }, { 85 | ignore: true 86 | }) 87 | ) 88 | .next(_ -> 89 | db.Clap 90 | .where(r -> r.user == christa.id && r.post == post) 91 | .first() 92 | ) 93 | .next(clap -> asserts.assert(clap.count == 1)) 94 | ) 95 | .next(_ -> asserts.done()); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/tink/sql/drivers/sys/StdConnection.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.drivers.sys; 2 | 3 | import tink.sql.Info; 4 | import tink.sql.Types; 5 | import tink.streams.Stream; 6 | import sys.db.ResultSet; 7 | import tink.sql.format.Formatter; 8 | import tink.sql.expr.ExprTyper; 9 | import tink.sql.parse.ResultParser; 10 | import tink.sql.format.Sanitizer; 11 | 12 | using tink.CoreApi; 13 | 14 | class StdConnection implements Connection { 15 | 16 | var db:Db; 17 | var cnx:sys.db.Connection; 18 | var formatter:Formatter<{}, {}>; 19 | var sanitizer:Sanitizer; 20 | var parser:ResultParser; 21 | 22 | public function new(db, cnx, formatter, sanitizer) { 23 | this.db = db; 24 | this.cnx = cnx; 25 | this.formatter = formatter; 26 | this.sanitizer = sanitizer; 27 | this.parser = new ResultParser(); 28 | } 29 | 30 | public function getFormatter() 31 | return formatter; 32 | 33 | public function execute(query:Query):Result { 34 | inline function fetch(): Promise 35 | return run(formatter.format(query).toString(sanitizer)); 36 | return switch query { 37 | case Select(_) | Union(_) | CallProcedure(_): 38 | Stream.promise(fetch().next(function (res:ResultSet) { 39 | var parse = parser.queryParser(query, formatter.isNested(query)); 40 | return Stream.ofIterator({ 41 | hasNext: function() return res.hasNext(), 42 | next: function () return parse(res.next()) 43 | }); 44 | })); 45 | case Transaction(_) | CreateTable(_, _) | DropTable(_) | AlterTable(_, _) | TruncateTable(_): 46 | fetch().next(function(_) return Noise); 47 | case Insert(_): 48 | fetch().next(function(_) return new Id(cnx.lastInsertId())); 49 | case Update(_) | Delete(_): 50 | fetch().next(function(res:ResultSet) 51 | return {rowsAffected: 52 | #if (!macro && php7) 53 | untyped cnx.db.affected_rows //haxefoundation/haxe#8433 54 | #else res.length #end 55 | }); 56 | case ShowColumns(_): 57 | fetch().next(function(res:ResultSet):Array 58 | return [for (row in res) formatter.parseColumn(cast row)] 59 | ); 60 | case ShowIndex(_): 61 | fetch().next(function(res:ResultSet):Array 62 | return formatter.parseKeys([for (row in res) cast row]) 63 | ); 64 | } 65 | } 66 | 67 | function run(query:String):Promise 68 | return OutcomeTools.attempt( 69 | function(): T return cast cnx.request(query), 70 | function (err) return new Error('$err') 71 | ); 72 | } -------------------------------------------------------------------------------- /tests/SelectTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import tink.unit.Assert.assert; 4 | import tink.sql.OrderBy; 5 | import Db; 6 | import tink.sql.Fields; 7 | import tink.sql.expr.Functions; 8 | 9 | using tink.CoreApi; 10 | 11 | @:asserts 12 | class SelectTest extends TestWithDb { 13 | 14 | @:setup @:access(Run) 15 | public function setup() { 16 | var run = new Run(driver, db); 17 | return Promise.inParallel([ 18 | db.Post.create(), 19 | db.User.create(), 20 | db.PostTags.create() 21 | ]) 22 | .next(function (_) return run.insertUsers()) 23 | .next(function(_) return Promise.inSequence([ 24 | run.insertPost('test', 'Alice', ['test', 'off-topic']), 25 | run.insertPost('test2', 'Alice', ['test']), 26 | run.insertPost('Some ramblings', 'Alice', ['off-topic']), 27 | run.insertPost('Just checking', 'Bob', ['test']), 28 | ])); 29 | } 30 | 31 | @:teardown 32 | public function teardown() { 33 | return Promise.inParallel([ 34 | db.Post.drop(), 35 | db.User.drop(), 36 | db.PostTags.drop(), 37 | ]); 38 | } 39 | 40 | public function selectJoin() 41 | return db.Post 42 | .join(db.User).on(Post.author == User.id) 43 | .select({ 44 | title: Post.title, 45 | name: User.name 46 | }) 47 | .where(Post.title == 'test') 48 | .first() 49 | .next(function(row) { 50 | return assert(row.title == 'test' && row.name == 'Alice'); 51 | }); 52 | 53 | public function selectGroupBy() 54 | return db.Post 55 | .select({ 56 | count: Functions.count(), 57 | }) 58 | .groupBy(function (fields) return [fields.author]) 59 | .having(function (fields) return Functions.count() > 1) 60 | .first() 61 | .next(function(row) { 62 | return assert(row.count == 3); 63 | }); 64 | 65 | public function selectWithFunction() { 66 | function getTag(p: Fields, u: Fields, t: Fields) 67 | return {tag: t.tag} 68 | return db.Post 69 | .join(db.User).on(Post.author == User.id) 70 | .join(db.PostTags).on(PostTags.post == Post.id) 71 | .select(getTag) 72 | .where(User.name == 'Alice' && Post.title == 'test2') 73 | .all() 74 | .next(function(rows) { 75 | return assert(rows.length == 1 && rows[0].tag == 'test'); 76 | }); 77 | } 78 | 79 | public function selectWithIfNull() 80 | // test IFNULL: only Bob has location == null (translated to "Unknown" here) 81 | return db.User 82 | .select({ 83 | name: User.name, 84 | location: tink.sql.expr.Functions.ifNull( User.location, "Unknown"), 85 | }) 86 | .all() 87 | .next( function(a) { 88 | for (o in a) { 89 | asserts.assert( (o.name == "Bob") == (o.location == "Unknown") ); 90 | } 91 | return asserts.done(); 92 | }) 93 | ; 94 | 95 | } 96 | -------------------------------------------------------------------------------- /src/tink/sql/expr/Functions.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.expr; 2 | 3 | import tink.sql.Types.Json; 4 | import tink.sql.Expr; 5 | import tink.s2d.*; 6 | 7 | class Functions { 8 | public static function iif(cond:Expr, ifTrue:Expr, ifFalse:Expr):Expr 9 | return ECall('IF', [cast cond, cast ifTrue, cast ifFalse], VTypeOf(ifTrue)); 10 | 11 | public static function ifNull(e:Field, fallbackValue:Expr):Expr 12 | return ECall('IFNULL', [cast e, cast fallbackValue], VTypeOf(e)); 13 | 14 | // Todo: count can also take an Expr 15 | public static function count(?e:Field):Expr 16 | return ECall('COUNT', if (e == null) cast [EValue(1, VInt)] else cast [e], VInt); 17 | 18 | public static function max(e:Field):Expr 19 | return ECall('MAX', cast [e], VTypeOf(e)); 20 | 21 | public static function min(e:Field):Expr 22 | return ECall('MIN', cast [e], VTypeOf(e)); 23 | 24 | public static function avg(e:Field):Expr 25 | return ECall('AVG', cast [e], VTypeOf(e)); 26 | 27 | public static function sum(e:Field):Expr 28 | return ECall('SUM', cast [e], VTypeOf(e)); 29 | 30 | public static function stContains(g1:Expr, g2:Expr):Expr 31 | return ECall('ST_Contains', cast [g1, g2], VBool); 32 | 33 | public static function stWithin(g1:Expr, g2:Expr):Expr 34 | return ECall('ST_Within', cast [g1, g2], VBool); 35 | 36 | public static function stDistanceSphere(g1:Expr, g2:Expr):Expr 37 | return ECall('ST_Distance_Sphere', cast [g1, g2], VFloat); 38 | 39 | public static function any(q:Scalar):Expr 40 | return ECall('ANY ', cast [q.toExpr()], VTypeOf(q), false); 41 | 42 | public static function some(q:Scalar):Expr 43 | return ECall('SOME ', cast [q.toExpr()], VTypeOf(q), false); 44 | 45 | public static function exists(q:Dataset):Condition 46 | return ECall('EXISTS ', cast [q.toExpr()], VBool, false); 47 | 48 | /** 49 | * MySQL: 50 | * Refer to column values from the INSERT portion of the INSERT ... ON DUPLICATE KEY UPDATE statement. 51 | * https://dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.html 52 | * 53 | * Postgres: 54 | * Traslates to `EXCLUDED.$field`. 55 | * https://www.postgresql.org/docs/current/sql-insert.html 56 | */ 57 | public static function values(e:Field) 58 | return ECall("VALUES", cast [e], VTypeOf(e), true); 59 | 60 | /** 61 | * JSON_VALUE was introduce in MySQL 8.0.21, not available in SQLite as of writing 62 | */ 63 | public static function jsonValue(jsonDoc:Expr>, path:Expr, returnType:ExprType):Expr 64 | return ECall('JSON_VALUE', cast ([jsonDoc, path]:Array), returnType); 65 | } 66 | -------------------------------------------------------------------------------- /src/tink/sql/macros/Helper.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.macros; 2 | 3 | import haxe.macro.Expr; 4 | import haxe.macro.Type; 5 | import haxe.macro.Context; 6 | 7 | using Lambda; 8 | using tink.MacroApi; 9 | 10 | class Helper { 11 | static var tempTypeCounter = 0; 12 | 13 | // Convert TypePath to expression. 14 | // For example we can use this to prepare a static field access of the given type 15 | // When the TypePath involves a type parameter, to work around Haxe's limitation, the logic will create a temporary typedef of the type first. 16 | // TODO: move this to tink_macro 17 | public static function typePathToExpr(path:TypePath, pos):Expr { 18 | return switch path.params { 19 | case null | []: 20 | final parts = path.pack.copy(); 21 | parts.push(path.name); 22 | if(path.sub != null) parts.push(path.sub); 23 | macro $p{parts}; 24 | case _: 25 | final tempPack = ['tink', 'sql', 'temptypes']; 26 | final tempName = 'Temp${tempTypeCounter++}'; 27 | Context.defineType({ 28 | pos: pos, 29 | pack: tempPack, 30 | name: tempName, 31 | kind: TDAlias(TPath(path)), 32 | fields: [], 33 | }); 34 | macro $p{tempPack.concat([tempName])}; 35 | } 36 | } 37 | 38 | public static function getDatabaseFields(type:Type, pos:Position):Array { 39 | return switch type { 40 | case TInst(_.get() => c = {isInterface: true}, _): 41 | final ret = []; 42 | 43 | 44 | for(field in type.getFields().sure()) { 45 | function extractMeta(name:String) { 46 | return switch field.meta.extract(name) { 47 | case []: null; 48 | case [{params:[]}]: field.name; 49 | case [{params:[v]}]: v.getName().sure(); 50 | default: field.pos.error('Invalid use of @$name'); 51 | } 52 | } 53 | 54 | switch extractMeta(':table') { 55 | case null: 56 | case table: 57 | ret.push({ 58 | name: field.name, 59 | kind: DFTable(table, field.type), 60 | pos: field.pos, 61 | }); 62 | } 63 | 64 | switch extractMeta(':procedure') { 65 | case null: 66 | case procedure: 67 | ret.push({ 68 | name: field.name, 69 | kind: DFProcedure(procedure, field.type), 70 | pos: field.pos, 71 | }); 72 | } 73 | } 74 | 75 | ret; 76 | 77 | case _: 78 | pos.error('[tink_sql] Expected interface'); 79 | } 80 | } 81 | } 82 | 83 | 84 | typedef DatabaseField = { 85 | final name:String; 86 | final kind:DatabaseFieldKind; 87 | final pos:Position; 88 | } 89 | 90 | enum DatabaseFieldKind { 91 | DFTable(name:String, type:Type); 92 | DFProcedure(name:String, type:Type); 93 | } -------------------------------------------------------------------------------- /tests/SchemaTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import Run.loadFixture; 4 | import tink.unit.AssertionBuffer; 5 | import tink.sql.Info; 6 | 7 | @:asserts 8 | class SchemaTest extends TestWithDb { 9 | 10 | function check(asserts: AssertionBuffer, version, inspect) { 11 | return loadFixture(db, 'schema_$version') 12 | .next(_ -> { 13 | var changes; 14 | return db.Schema.diffSchema(true) 15 | .next(function (changes) { 16 | inspect(changes); 17 | return changes; 18 | }) 19 | .next(db.Schema.updateSchema) 20 | .next(function (_) return db.Schema.diffSchema()) 21 | .next(function (diff) { 22 | changes = diff; 23 | asserts.assert(diff.length == 0); 24 | return asserts.done(); 25 | }); 26 | }); 27 | } 28 | 29 | public function diffIdentical() 30 | return check(asserts, 'identical', function(changes) { 31 | asserts.assert(changes.length == 0); 32 | }); 33 | 34 | public function diffEmpty() 35 | return check(asserts, 'empty', function(changes) { 36 | asserts.assert(changes.length == 27); 37 | }); 38 | 39 | public function diffPrefilled() 40 | return check(asserts, 'prefilled', function(changes) { 41 | asserts.assert(changes.length == 26); 42 | }); 43 | 44 | public function diffModify() 45 | return check(asserts, 'modify', function(changes) { 46 | for (change in changes) switch change { 47 | case AlterColumn(to = {name: 'toBoolean'}, from): 48 | asserts.assert(from.type.match(DDouble(null))); 49 | asserts.assert(to.type.match(DBool(null))); 50 | case AlterColumn(to = {name: 'toFloat'}, from): 51 | asserts.assert(from.type.match(DInt(Default, false, false, null))); 52 | asserts.assert(to.type.match(DDouble(null))); 53 | case AlterColumn(to = {name: 'toInt'}, from): 54 | asserts.assert(from.type.match(DBool(null))); 55 | asserts.assert(to.type.match(DInt(Default, true, false, null))); 56 | case AlterColumn(to = {name: 'toLongText'}, from): 57 | asserts.assert(from.type.match(DBool(null))); 58 | asserts.assert(to.type.match(DText(Default, null))); 59 | case AlterColumn(to = {name: 'toText'}, from): 60 | asserts.assert(from.type.match(DText(Default, null))); 61 | asserts.assert(to.type.match(DString(1))); 62 | case AlterColumn(to = {name: 'toDate'}, from): 63 | asserts.assert(from.type.match(DBool(null))); 64 | asserts.assert(to.type.match(DDateTime(null))); 65 | default: 66 | } 67 | asserts.assert(changes.length == 23); 68 | }); 69 | 70 | public function diffIndexes() 71 | return check(asserts, 'indexes', function(changes) { 72 | for (change in changes) switch change { 73 | case DropKey(Index('ab', _)): 74 | case DropKey(Unique('unique' | 'ef' | 'h' | 'indexed', _)): 75 | case DropKey(key): 76 | asserts.assert(false, 'Dropped key: $key'); 77 | case AddKey(Index('indexed' | 'ab' | 'cd', _)): 78 | case AddKey(Unique('unique' | 'ef' | 'gh', _)): 79 | case AddKey(key): 80 | asserts.assert(false, 'Added key: $key'); 81 | default: 82 | } 83 | }); 84 | 85 | } -------------------------------------------------------------------------------- /src/tink/sql/drivers/MySql.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.drivers; 2 | 3 | import haxe.Int64; 4 | import tink.sql.format.Sanitizer; 5 | import tink.core.Any; 6 | import haxe.io.Bytes; 7 | 8 | using StringTools; 9 | 10 | private typedef Impl = 11 | #if macro 12 | tink.sql.drivers.macro.Dummy; 13 | #elseif nodejs 14 | tink.sql.drivers.node.MySql; 15 | #elseif php 16 | tink.sql.drivers.php.PDO.PDOMysql; 17 | #else 18 | tink.sql.drivers.sys.MySql; 19 | #end 20 | 21 | private class MySqlSanitizer implements Sanitizer { 22 | 23 | static inline var BACKTICK = '`'.code; 24 | 25 | public function new() {} 26 | 27 | public function value(v:Any):String { 28 | if (Int64.isInt64(v)) v = Int64.toStr(v); 29 | if (Std.is(v, Bool)) return v ? 'true' : 'false'; 30 | if (v == null || Std.is(v, Int)) return '$v'; 31 | if (Std.is(v, Bytes)) v = (cast v: Bytes).toString(); 32 | 33 | if (Std.is(v, Date)) return 34 | #if mysql_session_timezone string((v: Date).toString()) 35 | #else 'DATE_ADD(FROM_UNIXTIME(0), INTERVAL ${(v: Date).getTime() / 1000} SECOND)' #end; 36 | 37 | return string('$v'); 38 | } 39 | 40 | public function ident(s:String) { 41 | //Remarks for `string` apply to this function also 42 | var buf = new StringBuf(); 43 | 44 | inline function tick() 45 | buf.addChar(BACKTICK); 46 | 47 | tick(); 48 | 49 | for (c in 0...s.length) 50 | switch s.fastCodeAt(c) { 51 | case BACKTICK: tick(); tick(); 52 | case v: buf.addChar(v); 53 | } 54 | 55 | tick(); 56 | 57 | return buf.toString(); 58 | } 59 | 60 | public function string(s:String) { 61 | /** 62 | * This is taken from https://github.com/felixge/node-mysql/blob/12979b273375971c28afc12a9d781bd0f7633820/lib/protocol/SqlString.js#L152 63 | * Writing your own escaping functions is questionable practice, but given that Felix worked with Oracle on this one, I think it should do. 64 | * 65 | * TODO: port these tests too: https://github.com/felixge/node-mysql/blob/master/test/unit/protocol/test-SqlString.js 66 | * TODO: optimize performance. The current implementation is very naive. 67 | */ 68 | var buf = new StringBuf(); 69 | 70 | buf.addChar('"'.code); 71 | 72 | for (c in 0...s.length) 73 | switch s.fastCodeAt(c) { 74 | case 0: buf.add('\\0'); 75 | case 8: buf.add('\\b'); 76 | case '\t'.code: buf.add('\\t'); 77 | case '\n'.code: buf.add('\\n'); 78 | case '\r'.code: buf.add('\\r'); 79 | case 0x1a: buf.add('\\Z'); 80 | case '"'.code: buf.add('\\"'); 81 | case '\''.code: buf.add('\\\''); 82 | case '\\'.code: buf.add('\\\\'); 83 | case v: buf.addChar(v); 84 | } 85 | 86 | buf.addChar('"'.code); 87 | 88 | return buf.toString(); 89 | } 90 | 91 | } 92 | 93 | abstract MySql(Impl) from Impl to Impl { 94 | public inline function new(settings) { 95 | this = new Impl(settings); 96 | } 97 | 98 | static var sanitizer = new MySqlSanitizer(); 99 | 100 | static public function getSanitizer(_:A) 101 | return sanitizer; 102 | } -------------------------------------------------------------------------------- /src/tink/sql/macros/Selects.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.macros; 2 | 3 | import haxe.macro.Context; 4 | import haxe.macro.Expr; 5 | using tink.MacroApi; 6 | 7 | class Selects { 8 | 9 | // This takes an expression of either an object holding tink.sql.Expr 10 | // values or a function returning one. We use the type of that expression 11 | // to modify the Result type of a new Dataset. 12 | static public function makeSelection(dataset:Expr, select:Null) { 13 | var arguments = switch Context.typeof(macro @:privateAccess $dataset.toCondition) { 14 | case TFun([{t: TFun(args, _)}], _): [ 15 | for (a in args) { 16 | name: a.name, 17 | type: a.t.toComplex({direct: true}) 18 | } 19 | ]; 20 | default: throw "assert"; 21 | } 22 | var posInfo: Map = new Map(); 23 | switch select { 24 | case {expr: EObjectDecl(fields)}: 25 | for (field in fields) { 26 | // Cast to Expr here to allow selecting subqueries 27 | // This is a little dirty and should probably be done another way, 28 | // as it doesn't allow passing subqueries through a method call 29 | var expr = field.expr; 30 | var blank = expr.pos.makeBlankType(); 31 | field.expr = macro @pos(field.pos) ($expr: tink.sql.Expr<$blank>); 32 | posInfo.set(field.field, expr.pos); 33 | } 34 | select = select.func(arguments).asExpr(); 35 | default: 36 | } 37 | var fields = macro $dataset.fields; 38 | var input = 39 | if (arguments.length > 1) 40 | [for (arg in arguments) fields.field(arg.name)] 41 | else 42 | [fields]; 43 | var call = macro $select($a{input}); 44 | var resultFields = []; 45 | var fieldExprTypes = []; 46 | switch Context.typeof(call) { 47 | case TAnonymous(_.get().fields => fields): 48 | // For each of the fields in the anonymous object we need 49 | // a result type, which can be found as T in ExprData 50 | for (field in fields) { 51 | var pos = 52 | if (posInfo.exists(field.name)) posInfo.get(field.name) 53 | else select.pos; 54 | resultFields.push({ 55 | name: field.name, 56 | pos: select.pos, 57 | kind: FProp('default', 'null', typeOfExpr(field.type, pos).toComplex()) 58 | }); 59 | } 60 | case v: throw 'Expected anonymous type as selection, got: $v'; 61 | } 62 | var resultType = TAnonymous(resultFields); 63 | var fieldsType = Context.typeof(fields).toComplex(); 64 | 65 | if (resultFields.length == 1) { 66 | var fieldType = switch resultFields[0].kind { 67 | case FProp(_, _, type): type; 68 | default: throw 'assert'; 69 | } 70 | fieldsType = (macro: tink.sql.Dataset.SingleField<$fieldType, $fieldsType>); 71 | } 72 | 73 | return macro @:pos(select.pos) (cast $call: tink.sql.Selection<$resultType, $fieldsType>); 74 | } 75 | 76 | static function typeOfExpr(type, pos: Position) 77 | return switch Context.followWithAbstracts(type) { 78 | case TEnum(_.get() => { 79 | pack: ['tink', 'sql'], name: 'ExprData' 80 | }, [p]): 81 | p; 82 | default: pos.error('Expected tink.sql.Expr, got: ${type.toComplex().toString()}'); 83 | } 84 | 85 | } -------------------------------------------------------------------------------- /tests/UpsertTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import tink.unit.Assert.assert; 4 | import tink.sql.OrderBy; 5 | import Db; 6 | import tink.sql.Fields; 7 | import tink.sql.expr.Functions; 8 | import tink.sql.Types; 9 | 10 | using tink.CoreApi; 11 | 12 | @:asserts 13 | class UpsertTest extends TestWithDb { 14 | var post:Id; 15 | 16 | @:setup @:access(Run) 17 | public function setup() { 18 | var run = new Run(driver, db); 19 | return Promise.inParallel([ 20 | db.User.create(), 21 | db.Post.create(), 22 | db.Clap.create(), 23 | ]) 24 | .next(function (_) return run.insertUsers()) 25 | .next(function (_) return db.User.where(r -> r.name == "Bob").first()) 26 | .next(function (bob) return db.Post.insertOne({ 27 | id: null, 28 | title: "Bob's post", 29 | author: bob.id, 30 | content: 'A wonderful post by Bob', 31 | })) 32 | .next(function (post) return this.post = post); 33 | } 34 | 35 | @:teardown 36 | public function teardown() { 37 | return Promise.inParallel([ 38 | db.User.drop(), 39 | db.Post.drop(), 40 | db.Clap.drop(), 41 | ]); 42 | } 43 | 44 | public function insertSimple() 45 | return db.User.where(r -> r.name == "Alice") 46 | .first() 47 | .next(currentAlice -> { 48 | db.User.insertOne({ 49 | id: currentAlice.id, 50 | name: currentAlice.name, 51 | email: currentAlice.email, 52 | location: currentAlice.location 53 | }, { 54 | update: u -> [u.name.set('Alice 2')], 55 | }).next(newAliceId -> { 56 | db.User.where(r -> r.id == newAliceId).first(); 57 | }).next(newAlice -> { 58 | asserts.assert(newAlice.id == currentAlice.id); 59 | asserts.assert(newAlice.name == "Alice 2"); 60 | asserts.done(); 61 | }); 62 | }); 63 | 64 | public function insertValue() { 65 | var newEmail = "bob@gmail.com"; 66 | return db.User.where(r -> r.name == "Bob") 67 | .first() 68 | .next(bob -> { 69 | db.User.insertOne({ 70 | id: bob.id, 71 | name: bob.name, 72 | email: newEmail, 73 | location: bob.location 74 | }, { 75 | update: u -> [u.email.set(Functions.values(u.email))], 76 | }).next(_ -> { 77 | db.User.where(r -> r.id == bob.id).first(); 78 | }).next(bob -> { 79 | asserts.assert(bob.email == newEmail); 80 | asserts.done(); 81 | }); 82 | }); 83 | } 84 | 85 | public function compositePrimaryKey() { 86 | return db.User.where(r -> r.name == "Christa").first() 87 | .next(christa -> 88 | db.Clap.insertOne({ 89 | user: christa.id, 90 | post: post, 91 | count: 1, 92 | }) 93 | .next(_ -> 94 | db.Clap 95 | .where(r -> r.user == christa.id && r.post == post) 96 | .first() 97 | ) 98 | .next(clap -> asserts.assert(clap.count == 1)) 99 | .next(_ -> 100 | db.Clap.insertOne({ 101 | user: christa.id, 102 | post: post, 103 | count: 1, 104 | }, { 105 | update: f -> [ 106 | f.count.set(f.count + 1), 107 | ] 108 | }) 109 | ) 110 | .next(_ -> 111 | db.Clap 112 | .where(r -> r.user == christa.id && r.post == post) 113 | .first() 114 | ) 115 | .next(clap -> asserts.assert(clap.count == 2)) 116 | ) 117 | .next(_ -> asserts.done()); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/tink/sql/expr/ExprTyper.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.expr; 2 | 3 | import tink.sql.Info; 4 | import tink.sql.Expr; 5 | import tink.sql.format.SqlFormatter; 6 | 7 | typedef TypeMap = Map>; 8 | 9 | class ExprTyper { 10 | static function typeColumn(type:DataType) 11 | return switch type { 12 | case DBool(_): (ExprType.VBool: ExprType); 13 | case DInt(Big, _, _, _): ExprType.VInt64; 14 | case DInt(_, _, _, _): ExprType.VInt; 15 | case DDouble(_): ExprType.VFloat; 16 | case DString(_, _) | DText(_, _): ExprType.VString; 17 | case DJson: ExprType.VJson; 18 | case DBlob(_): ExprType.VBytes; 19 | case DDate(_) | DDateTime(_) | DTimestamp(_): ExprType.VDate; 20 | case DPoint: ExprType.VGeometry(Point); 21 | case DLineString: ExprType.VGeometry(LineString); 22 | case DPolygon: ExprType.VGeometry(Polygon); 23 | case DMultiPoint: ExprType.VGeometry(MultiPoint); 24 | case DMultiLineString: ExprType.VGeometry(MultiLineString); 25 | case DMultiPolygon: ExprType.VGeometry(MultiPolygon); 26 | case DUnknown(_, _): null; 27 | } 28 | 29 | static function nameField(table:String, field:String, ?alias): String 30 | return (if (alias != null) alias else table) + SqlFormatter.FIELD_DELIMITER + field; 31 | 32 | static function typeTarget(target:Target, nest = false):TypeMap 33 | return switch target { 34 | case TQuery(alias, query): 35 | var types = typeQuery(query); 36 | [ 37 | for (field in types.keys()) 38 | nameField(alias, field) => types[field] 39 | ]; 40 | case TTable(table): 41 | [ 42 | for (column in table.getColumns()) 43 | ( 44 | if (nest) nameField(table.getName(), column.name, table.getAlias()) 45 | else column.name 46 | ) => typeColumn(column.type) 47 | ]; 48 | case TJoin(left, right, _, _): 49 | var res = typeTarget(left, true); 50 | var add = typeTarget(right, true); 51 | for (field in add.keys()) 52 | res[field] = add[field]; 53 | res; 54 | } 55 | 56 | public static function typeQuery(query:Query):TypeMap { 57 | return switch query { 58 | case Select({selection: selection}) if (selection != null): 59 | [ 60 | for (key in selection.keys()) 61 | key => type(selection[key]) 62 | ]; 63 | case Select({from: target}): 64 | typeTarget(target); 65 | case Union({left: left}): 66 | typeQuery(left); 67 | case CallProcedure(_): 68 | new Map(); 69 | default: 70 | throw 'cannot type non selection: $query'; 71 | } 72 | } 73 | 74 | public static function type(expr:Expr):ExprType { 75 | var res:ExprType = switch expr.data { 76 | case EField(_, _, type): type; 77 | case EValue(_, type): type; 78 | case EQuery(_, type): type; 79 | case EBinOp(Add | Subt | Mult | Mod | Div, _, _): ExprType.VFloat; 80 | case EBinOp(_, _, _): ExprType.VBool; 81 | case EUnOp(_, _, _): ExprType.VBool; 82 | case ECall(_, _, type, _): type; 83 | case EReturning(type): type; 84 | } 85 | return switch res { 86 | case VTypeOf(expr): type(expr); 87 | case v: v; 88 | } 89 | } 90 | } -------------------------------------------------------------------------------- /src/tink/sql/Query.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | import tink.sql.Expr; 4 | import tink.sql.Info; 5 | import tink.sql.Limit; 6 | import tink.sql.Types; 7 | import tink.streams.RealStream; 8 | 9 | using tink.CoreApi; 10 | 11 | enum Query { 12 | // Multi(queries: Array>):Query>; 13 | Union(union:UnionOperation):Query>; 14 | Select(select:SelectOperation):Query>; 15 | Insert(insert:InsertOperation):Query>; 16 | Update(update:UpdateOperation):Query>; 17 | Delete(delete:DeleteOperation):Query>; 18 | CallProcedure(call:CallOperation):Query>; 19 | CreateTable(table:TableInfo, ?ifNotExists:Bool):Query>; 20 | DropTable(table:TableInfo):Query>; 21 | TruncateTable(table:TableInfo):Query>; 22 | AlterTable(table:TableInfo, changes:Array):Query>; 23 | ShowColumns(from:TableInfo):Query>>; 24 | ShowIndex(from:TableInfo):Query>>; 25 | Transaction(transaction:TransactionOperation):Query>; 26 | } 27 | 28 | typedef UnionOperation = { 29 | left:Query>, 30 | right:Query>, 31 | distinct:Bool, 32 | ?limit:Limit 33 | } 34 | 35 | typedef SelectOperation = { 36 | from:Target, 37 | ?selection:Selection, 38 | ?where:Condition, 39 | ?limit:Limit, 40 | ?orderBy:OrderBy, 41 | ?groupBy:Array>, 42 | ?having:Condition 43 | } 44 | 45 | typedef UpdateOperation = { 46 | table:TableInfo, 47 | set:Update, 48 | ?where:Condition, 49 | ?max:Int 50 | } 51 | 52 | typedef CallOperation = { 53 | name:String, 54 | arguments:Array>, 55 | ?limit:Limit, 56 | } 57 | 58 | typedef Update = Array>; 59 | 60 | class FieldUpdate { 61 | public var field(default, null):Field; 62 | public var expr(default, null):Expr; 63 | 64 | public function new(field:Field, expr:Expr) { 65 | this.field = field; 66 | this.expr = expr; 67 | } 68 | } 69 | 70 | typedef DeleteOperation = { 71 | from:TableInfo, 72 | ?where:Condition, 73 | ?max:Int 74 | } 75 | 76 | typedef InsertOperation = { 77 | table:TableInfo, 78 | data:InsertData, 79 | ?ignore:Bool, // mysql: INSERT IGNORE, postgres: ON CONFLICT DO NOTHING 80 | ?replace:Bool, // mysql only: REPLACE INTO 81 | ?update:Update, // mysql: ON DUPLICATE KEY UPDATE, postgres: ON CONFLICT (primary key) DO UPDATE SET 82 | } 83 | 84 | enum InsertData { 85 | Literal(data:Array); 86 | Select(op:SelectOperation); 87 | } 88 | 89 | enum AlterTableOperation { 90 | AddColumn(col:Column); 91 | AddKey(key:Key); 92 | AlterColumn(to:Column, ?from:Column); 93 | DropColumn(col:Column); 94 | DropKey(key:Key); 95 | } 96 | 97 | enum TransactionOperation { 98 | Start; 99 | Commit; 100 | Rollback; 101 | } -------------------------------------------------------------------------------- /src/tink/sql/Schema.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | import tink.sql.Info; 4 | import tink.sql.Query; 5 | import tink.sql.format.Formatter; 6 | 7 | class Schema { 8 | var columns:Map; 9 | var keys:Map; 10 | 11 | public function new(columns:Array, keys:Array) { 12 | this.columns = [ 13 | for (column in columns) 14 | column.name => column 15 | ]; 16 | this.keys = [ 17 | for (key in keys) 18 | keyName(key) => key 19 | ]; 20 | } 21 | 22 | function keyName(key) 23 | return switch key { 24 | case Primary(_): 'primary'; 25 | case Unique(name, _) | Index(name, _): name; 26 | } 27 | 28 | function hasAutoIncrement(column:Column) 29 | return switch column.type { 30 | case DInt(_, _, true, _): true; 31 | default: false; 32 | } 33 | 34 | function withoutAutoIncrement(column:Column) 35 | return switch column.type { 36 | case DInt(bits, signed, true, defaultValue): { 37 | name: column.name, 38 | nullable: column.nullable, 39 | writable: column.writable, 40 | type: DInt(bits, signed, false, defaultValue) 41 | } 42 | default: column; 43 | } 44 | 45 | public function diff(that: Schema, formatter:Formatter<{}, {}>):Array { 46 | var changes = [], post = []; 47 | // The sanitizer will not actually be used to form sql queries, only to 48 | // compare potential output 49 | var sanitizer = tink.sql.drivers.MySql.getSanitizer(null); 50 | for (key in mergeKeys(this.columns, that.columns)) 51 | switch [this.columns[key], that.columns[key]] { 52 | case [null, added]: 53 | if (hasAutoIncrement(added)) { 54 | var without = withoutAutoIncrement(added); 55 | changes.unshift(AddColumn(without)); 56 | post.push(AlterColumn(added, without)); 57 | } else { 58 | changes.unshift(AddColumn(added)); 59 | } 60 | case [removed, null]: changes.push(DropColumn(removed)); 61 | case [a, b]: 62 | if (formatter.defineColumn(a).toString(sanitizer) == formatter.defineColumn(b).toString(sanitizer)) 63 | continue; 64 | if (hasAutoIncrement(b)) { 65 | var without = withoutAutoIncrement(b); 66 | if (formatter.defineColumn(a).toString(sanitizer) != formatter.defineColumn(without).toString(sanitizer)) 67 | changes.unshift(AlterColumn(without, a)); 68 | post.push(AlterColumn(b, without)); 69 | } else { 70 | changes.push(AlterColumn(b, a)); 71 | } 72 | } 73 | for (name in mergeKeys(this.keys, that.keys)) 74 | switch [this.keys[name], that.keys[name]] { 75 | case [null, added]: changes.push(AddKey(added)); 76 | case [removed, null]: 77 | changes.unshift(DropKey(removed)); 78 | case [a, b]: 79 | if (formatter.defineKey(a).toString(sanitizer) == formatter.defineKey(b).toString(sanitizer)) 80 | continue; 81 | changes.unshift(DropKey(a)); 82 | changes.push(AddKey(b)); 83 | } 84 | return changes.concat(post); 85 | } 86 | 87 | static function mergeKeys(a: Map, b: Map) 88 | return [for (key in a.keys()) key].concat([ 89 | for (key in b.keys()) 90 | if (!a.exists(key)) key 91 | ]); 92 | 93 | } -------------------------------------------------------------------------------- /tests/IdTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import Db; 4 | import tink.sql.Types; 5 | import haxe.Int64; 6 | using tink.CoreApi; 7 | 8 | @:asserts 9 | class IdTest { 10 | public function new():Void {} 11 | 12 | public function arithmetics() { 13 | final id:Id = 123; 14 | asserts.assert(id == 123); 15 | asserts.assert(id + 1 == 124); 16 | asserts.assert(id - 1 == 122); 17 | return asserts.done(); 18 | } 19 | public function arithmetics64() { 20 | final id:Id64 = Int64.ofInt(123); 21 | asserts.assert(id == Int64.ofInt(123)); 22 | asserts.assert(id + 1 == Int64.ofInt(124)); 23 | asserts.assert(id - 1 == Int64.ofInt(122)); 24 | return asserts.done(); 25 | } 26 | 27 | public function addAssign() { 28 | final id:Id = 123; 29 | asserts.assert(id == 123); 30 | asserts.assert((id += 1) == 124); 31 | return asserts.done(); 32 | } 33 | public function addAssign64() { 34 | final id:Id64 = Int64.ofInt(123); 35 | asserts.assert(id == Int64.ofInt(123)); 36 | asserts.assert((id += 1) == Int64.ofInt(124)); 37 | return asserts.done(); 38 | } 39 | public function minusAssign() { 40 | final id:Id = 123; 41 | asserts.assert(id == 123); 42 | asserts.assert((id -= 1) == 122); 43 | return asserts.done(); 44 | } 45 | public function minusAssign64() { 46 | final id:Id64 = Int64.ofInt(123); 47 | asserts.assert(id == Int64.ofInt(123)); 48 | asserts.assert((id -= 1) == Int64.ofInt(122)); 49 | return asserts.done(); 50 | } 51 | 52 | public function postfixInc() { 53 | final id:Id = 123; 54 | asserts.assert(id == 123); 55 | asserts.assert(id++ == 123); 56 | asserts.assert(id == 124); 57 | return asserts.done(); 58 | } 59 | public function postfixInc64() { 60 | final id:Id64 = Int64.ofInt(123); 61 | asserts.assert(id == Int64.ofInt(123)); 62 | asserts.assert(id++ == Int64.ofInt(123)); 63 | asserts.assert(id == Int64.ofInt(124)); 64 | return asserts.done(); 65 | } 66 | public function postfixDec() { 67 | final id:Id = 123; 68 | asserts.assert(id == 123); 69 | asserts.assert(id-- == 123); 70 | asserts.assert(id == 122); 71 | return asserts.done(); 72 | } 73 | public function postfixDec64() { 74 | final id:Id64 = Int64.ofInt(123); 75 | asserts.assert(id == Int64.ofInt(123)); 76 | asserts.assert(id-- == Int64.ofInt(123)); 77 | asserts.assert(id == Int64.ofInt(122)); 78 | return asserts.done(); 79 | } 80 | public function prefixInc() { 81 | final id:Id = 123; 82 | asserts.assert(id == 123); 83 | asserts.assert(++id == 124); 84 | asserts.assert(id == 124); 85 | return asserts.done(); 86 | } 87 | public function prefixInc64() { 88 | final id:Id64 = Int64.ofInt(123); 89 | asserts.assert(id == Int64.ofInt(123)); 90 | asserts.assert(++id == Int64.ofInt(124)); 91 | asserts.assert(id == Int64.ofInt(124)); 92 | return asserts.done(); 93 | } 94 | public function prefixDec() { 95 | final id:Id = 123; 96 | asserts.assert(id == 123); 97 | asserts.assert(--id == 122); 98 | asserts.assert(id == 122); 99 | return asserts.done(); 100 | } 101 | public function prefixDec64() { 102 | final id:Id64 = Int64.ofInt(123); 103 | asserts.assert(id == Int64.ofInt(123)); 104 | asserts.assert(--id == Int64.ofInt(122)); 105 | asserts.assert(id == Int64.ofInt(122)); 106 | return asserts.done(); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/tink/sql/parse/ResultParser.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.parse; 2 | 3 | import haxe.Int64; 4 | import tink.sql.Expr; 5 | import haxe.DynamicAccess; 6 | import tink.sql.format.SqlFormatter; 7 | import tink.sql.expr.ExprTyper; 8 | import haxe.io.Bytes; 9 | import haxe.io.BytesInput; 10 | 11 | using tink.CoreApi; 12 | 13 | class ResultParser { 14 | public function new() {} 15 | 16 | function parseGeometryValue(bytes:Bytes):Any { 17 | return switch tink.spatial.Parser.wkb(bytes.sub(4, bytes.length - 4)) { 18 | case S2D(Point(v)): v; 19 | case S2D(LineString(v)): v; 20 | case S2D(Polygon(v)): v; 21 | case S2D(MultiPoint(v)): v; 22 | case S2D(MultiLineString(v)): v; 23 | case S2D(MultiPolygon(v)): v; 24 | case S2D(GeometryCollection(v)): v; 25 | case _: throw 'expected 2d geometries'; 26 | } 27 | } 28 | 29 | function parseValue(value:Dynamic, type:ExprType): Any { 30 | if (value == null) return null; 31 | return switch type { 32 | case null: value; 33 | case ExprType.VBool if (Std.is(value, String)): 34 | value == '1'; 35 | case ExprType.VBool if (Std.is(value, Int)): 36 | value > 0; 37 | case ExprType.VBool: !!value; 38 | case ExprType.VString: 39 | '${value}'; 40 | case ExprType.VFloat if (Std.is(value, String)): 41 | Std.parseFloat(value); 42 | case ExprType.VInt if (Std.is(value, String)): 43 | Std.parseInt(value); 44 | case ExprType.VInt64 if (Std.is(value, String)): 45 | Int64.parseString(value); 46 | case ExprType.VDate if (Std.is(value, String)): 47 | Date.fromString(value); 48 | case ExprType.VDate if (Std.is(value, Float)): 49 | Date.fromTime(value); 50 | #if js 51 | case ExprType.VBytes if (Std.is(value, js.node.Buffer)): 52 | (value: js.node.Buffer).hxToBytes(); 53 | #end 54 | case ExprType.VBytes if (Std.is(value, String)): 55 | haxe.io.Bytes.ofString(value); 56 | case ExprType.VGeometry(_): 57 | if (Std.is(value, String)) parseGeometryValue(Bytes.ofString(value)) 58 | else if (Std.is(value, Bytes)) parseGeometryValue(value) 59 | else value; 60 | case ExprType.VJson: 61 | haxe.Json.parse(value); 62 | default: value; 63 | } 64 | } 65 | 66 | public function queryParser( 67 | query:Query, 68 | nest:Bool 69 | ): DynamicAccess -> Row { 70 | var types = ExprTyper.typeQuery(query); 71 | return function (row: DynamicAccess) { 72 | var res: DynamicAccess = {} 73 | var nonNull = new Map(); 74 | for (field in row.keys()) { 75 | var value = parseValue( 76 | row[field], 77 | types.get(field) 78 | ); 79 | if (nest) { 80 | var parts = field.split(SqlFormatter.FIELD_DELIMITER); 81 | var table = parts[0]; 82 | var name = parts[1]; 83 | var target: DynamicAccess = 84 | if (!res.exists(table)) res[table] = {}; 85 | else res[table]; 86 | target[name] = value; 87 | if (value != null) nonNull.set(table, true); 88 | } else { 89 | res[field] = value; 90 | } 91 | } 92 | if (nest) { 93 | for (table in res.keys()) 94 | if (!nonNull.exists(table)) 95 | res.remove(table); 96 | } 97 | return cast res; 98 | } 99 | } 100 | } -------------------------------------------------------------------------------- /tests/GeometryTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import tink.s2d.*; 4 | import tink.s2d.Point.latLng as point; 5 | import tink.sql.Expr; 6 | import tink.unit.Assert.assert; 7 | 8 | using tink.CoreApi; 9 | 10 | @:asserts 11 | class GeometryTest extends TestWithDb { 12 | 13 | @:before 14 | public function createTable() { 15 | return db.Geometry.drop().flatMap(function(_) return db.Geometry.create()); 16 | } 17 | 18 | public function insert() { 19 | return db.Geometry.insertOne({ 20 | point: point(1.0, 2.0), 21 | lineString: new LineString([point(1.0, 2.0), point(2.0, 3.0)]), 22 | polygon: new Polygon([new LineString([point(1.0, 2.0), point(2.0, 3.0), point(3.0, 4.0), point(1.0, 2.0)])]), 23 | }) 24 | .swap(assert(true)); 25 | } 26 | 27 | public function retrieve() { 28 | return db.Geometry.insertOne({ 29 | point: point(1.0, 2.0), 30 | lineString: new LineString([point(1.0, 2.0), point(2.0, 3.0)]), 31 | polygon: new Polygon([new LineString([point(1.0, 2.0), point(2.0, 3.0), point(3.0, 4.0), point(1.0, 2.0)])]), 32 | }) 33 | .next(function(_) return db.Geometry.first()) 34 | .next(function(row) { 35 | asserts.assert(row.point.latitude == 1.0); 36 | asserts.assert(row.point.longitude == 2.0); 37 | asserts.assert(row.lineString.length == 2); 38 | asserts.assert(row.lineString[0].latitude == 1.0); 39 | asserts.assert(row.lineString[0].longitude == 2.0); 40 | asserts.assert(row.lineString[1].latitude == 2.0); 41 | asserts.assert(row.lineString[1].longitude == 3.0); 42 | asserts.assert(row.polygon.length == 1); 43 | asserts.assert(row.polygon[0][0].latitude == 1.0); 44 | asserts.assert(row.polygon[0][0].longitude == 2.0); 45 | asserts.assert(row.polygon[0][1].latitude == 2.0); 46 | asserts.assert(row.polygon[0][1].longitude == 3.0); 47 | asserts.assert(row.polygon[0][2].latitude == 3.0); 48 | asserts.assert(row.polygon[0][2].longitude == 4.0); 49 | asserts.assert(row.polygon[0][3].latitude == 1.0); 50 | asserts.assert(row.polygon[0][3].longitude == 2.0); 51 | return asserts.done(); 52 | }); 53 | } 54 | 55 | public function distance0() { 56 | return db.Geometry.insertOne({ 57 | point: point(1.0, 2.0), 58 | lineString: new LineString([point(1.0, 2.0), point(2.0, 3.0)]), 59 | polygon: new Polygon([new LineString([point(1.0, 2.0), point(2.0, 3.0), point(3.0, 4.0), point(1.0, 2.0)])]), 60 | }) 61 | .next(function(_) return db.Geometry.where(Functions.stDistanceSphere(Geometry.point, point(1.0, 2.0)) == 0).first()) 62 | .next(function(row) { 63 | asserts.assert(row.point.latitude == 1.0); 64 | asserts.assert(row.point.longitude == 2.0); 65 | return asserts.done(); 66 | }); 67 | } 68 | 69 | public function distanceZero180() { 70 | return db.Geometry.insertOne({ 71 | point: point(0, 0), 72 | optionalPoint: point(0, 180), 73 | lineString: new LineString([point(1.0, 2.0), point(2.0, 3.0)]), 74 | polygon: new Polygon([new LineString([point(1.0, 2.0), point(2.0, 3.0), point(3.0, 4.0), point(1.0, 2.0)])]), 75 | }) 76 | .next(_ -> db.Geometry.select(f -> { 77 | distance: Functions.stDistanceSphere(f.point, f.optionalPoint) 78 | }).first()) 79 | .next(function(row) { 80 | // do not use `==` to compare directly since MySQL and Postgres (and probably other DBs) implement stDistanceSphere differently 81 | // https://dba.stackexchange.com/questions/191266/mysql-gis-functions-strange-results-from-st-distance-sphere 82 | asserts.assert(row.distance > 20015000); 83 | asserts.assert(row.distance < 20016000); 84 | return asserts.done(); 85 | }); 86 | } 87 | } -------------------------------------------------------------------------------- /tests/ExprTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import Db; 4 | import tink.unit.Assert.assert; 5 | 6 | class ExprTest extends TestWithDb { 7 | 8 | public function expr() { 9 | // these should compile: 10 | db.Types.where(Types.text == 't'); 11 | db.Types.where(Types.text != 't'); 12 | db.Types.where(Types.text.inArray(['t'])); 13 | db.Types.where(Types.text.like('t')); 14 | 15 | db.Types.where(Types.abstractString == 't'); 16 | db.Types.where(Types.abstractString != 't'); 17 | db.Types.where(Types.abstractString.inArray(['t'])); 18 | db.Types.where(Types.abstractString.like('t')); 19 | 20 | db.Types.where(Types.enumAbstractString == S); 21 | db.Types.where(Types.enumAbstractString != S); 22 | db.Types.where(Types.enumAbstractString.inArray([S])); 23 | db.Types.where(Types.enumAbstractString.like(S)); 24 | 25 | db.Types.where(Types.int == 1); 26 | db.Types.where(Types.int != 1); 27 | db.Types.where(Types.int > 1); 28 | db.Types.where(Types.int < 1); 29 | db.Types.where(Types.int >= 1); 30 | db.Types.where(Types.int <= 1); 31 | db.Types.where(Types.int.inArray([1])); 32 | 33 | db.Types.where(Types.abstractInt == 1); 34 | db.Types.where(Types.abstractInt != 1); 35 | db.Types.where(Types.abstractInt > 1); 36 | db.Types.where(Types.abstractInt < 1); 37 | db.Types.where(Types.abstractInt >= 1); 38 | db.Types.where(Types.abstractInt <= 1); 39 | db.Types.where(Types.abstractInt.inArray([1])); 40 | 41 | db.Types.where(Types.enumAbstractInt == I); 42 | db.Types.where(Types.enumAbstractInt != I); 43 | db.Types.where(Types.enumAbstractInt > I); 44 | db.Types.where(Types.enumAbstractInt < I); 45 | db.Types.where(Types.enumAbstractInt >= I); 46 | db.Types.where(Types.enumAbstractInt <= I); 47 | db.Types.where(Types.enumAbstractInt.inArray([I])); 48 | 49 | db.Types.where(Types.float == 1.); 50 | db.Types.where(Types.float != 1.); 51 | db.Types.where(Types.float > 1.); 52 | db.Types.where(Types.float < 1.); 53 | db.Types.where(Types.float >= 1.); 54 | db.Types.where(Types.float <= 1.); 55 | db.Types.where(Types.float.inArray([1.])); 56 | 57 | db.Types.where(Types.abstractFloat == 1.); 58 | db.Types.where(Types.abstractFloat != 1.); 59 | db.Types.where(Types.abstractFloat > 1.); 60 | db.Types.where(Types.abstractFloat < 1.); 61 | db.Types.where(Types.abstractFloat >= 1.); 62 | db.Types.where(Types.abstractFloat <= 1.); 63 | db.Types.where(Types.abstractFloat.inArray([1.])); 64 | 65 | db.Types.where(Types.enumAbstractFloat == F); 66 | db.Types.where(Types.enumAbstractFloat != F); 67 | db.Types.where(Types.enumAbstractFloat > F); 68 | db.Types.where(Types.enumAbstractFloat < F); 69 | db.Types.where(Types.enumAbstractFloat >= F); 70 | db.Types.where(Types.enumAbstractFloat <= F); 71 | db.Types.where(Types.enumAbstractFloat.inArray([F])); 72 | 73 | db.Types.where(Types.boolTrue == true); 74 | db.Types.where(Types.boolTrue); 75 | db.Types.where(!Types.boolTrue); 76 | 77 | // db.Types.where(Types.abstractBool == true); 78 | // db.Types.where(Types.abstractBool); 79 | // db.Types.where(!Types.abstractBool); 80 | 81 | // db.Types.where(Types.enumAbstractSBool == B); 82 | // db.Types.where(Types.enumAbstractSBool); 83 | // db.Types.where(!Types.enumAbstractSBool); 84 | 85 | // db.Types.where(Types.date == Date.now()); 86 | // db.Types.where(Types.date != Date.now()); 87 | // db.Types.where(Types.date > Date.now()); 88 | // db.Types.where(Types.date < Date.now()); 89 | // db.Types.where(Types.date >= Date.now()); 90 | // db.Types.where(Types.date <= Date.now()); 91 | 92 | return assert(true); 93 | } 94 | } -------------------------------------------------------------------------------- /tests/JsonTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import Db; 4 | import tink.sql.Info; 5 | import tink.sql.Expr.Functions.*; 6 | import tink.unit.Assert.assert; 7 | 8 | using tink.CoreApi; 9 | 10 | @:asserts 11 | @:allow(tink.unit) 12 | class JsonTest extends TestWithDb { 13 | 14 | @:before 15 | public function createTable() { 16 | return db.JsonTypes.create(); 17 | } 18 | 19 | @:after 20 | public function dropTable() { 21 | return db.JsonTypes.drop(); 22 | } 23 | 24 | public function insert() { 25 | var future = db.JsonTypes.insertOne({ 26 | id: null, 27 | jsonNull: null, 28 | jsonTrue: true, 29 | jsonFalse: false, 30 | jsonInt: 123, 31 | jsonFloat: 123.4, 32 | jsonArrayInt: [1,2,3], 33 | jsonObject: {"a":1, "b":2}, 34 | }) 35 | .next(function(id:Int) return db.JsonTypes.first()) 36 | .next(function(row:JsonTypes) { 37 | asserts.assert(row.jsonNull == null); 38 | asserts.assert(row.jsonTrue == true); 39 | asserts.assert(row.jsonFalse == false); 40 | asserts.assert(row.jsonInt == 123); 41 | asserts.assert(row.jsonFloat == 123.4); 42 | asserts.assert(haxe.Json.stringify(row.jsonArrayInt) == haxe.Json.stringify([1,2,3])); 43 | asserts.assert(haxe.Json.stringify(row.jsonObject) == haxe.Json.stringify({"a":1, "b":2})); 44 | asserts.assert(row.jsonOptNull == null); 45 | return Noise; 46 | }) 47 | .next(function(_) return db.JsonTypes.where(r -> r.jsonOptNull.isNull()).count()) 48 | .next(function(count:Int) { 49 | asserts.assert(count == 1); 50 | return Noise; 51 | }) 52 | .next(function(_) return db.JsonTypes.where(r -> r.jsonNull.isNull()).count()) 53 | .next(function(count:Int) { 54 | asserts.assert(count == 0); 55 | return Noise; 56 | }); 57 | 58 | future.handle(function(o) switch o { 59 | case Success(_): asserts.done(); 60 | case Failure(e): asserts.fail(e); 61 | }); 62 | 63 | return asserts; 64 | } 65 | 66 | @:exclude //JSON_VALUE was added in MySQL 8.0.21, not available in SQLite as of writing 67 | public function test_jsonValue() { 68 | var future = db.JsonTypes.insertOne({ 69 | id: null, 70 | jsonNull: null, 71 | jsonTrue: true, 72 | jsonFalse: false, 73 | jsonInt: 123, 74 | jsonFloat: 123.4, 75 | jsonArrayInt: [1,2,3], 76 | jsonObject: {"a":1, "b":"B"}, 77 | }) 78 | .next(function(_) return db.JsonTypes.where(r -> jsonValue(r.jsonObject, "$.a", VInt) == 1).first()) 79 | .next(function(row:JsonTypes) { 80 | asserts.assert(row.jsonObject.a == 1); 81 | return Noise; 82 | }) 83 | .next(function(_) return db.JsonTypes.where(r -> jsonValue(r.jsonObject, "$.b", VString) == "B").first()) 84 | .next(function(row:JsonTypes) { 85 | asserts.assert(row.jsonObject.b == "B"); 86 | return Noise; 87 | }) 88 | .next(function(_) return db.JsonTypes.where(r -> jsonValue(r.jsonArrayInt, "$[2]", VInt) == 3).first()) 89 | .next(function(row:JsonTypes) { 90 | asserts.assert(row.jsonArrayInt[2] == 3); 91 | return Noise; 92 | }) 93 | .next(function(_) return db.JsonTypes.where(r -> jsonValue(r.jsonTrue, "$", VBool) == true).first()) 94 | .next(function(row:JsonTypes) { 95 | asserts.assert(row.jsonTrue == true); 96 | return Noise; 97 | }) 98 | .next(function(_) return db.JsonTypes.where(r -> jsonValue(r.jsonFloat, "$", VFloat) == 123.4).first()) 99 | .next(function(row:JsonTypes) { 100 | asserts.assert(row.jsonFloat == 123.4); 101 | return Noise; 102 | }); 103 | 104 | future.handle(function(o) switch o { 105 | case Success(_): asserts.done(); 106 | case Failure(e): asserts.fail(e); 107 | }); 108 | 109 | return asserts; 110 | } 111 | } -------------------------------------------------------------------------------- /src/tink/sql/macros/Targets.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.macros; 2 | 3 | import haxe.macro.Expr; 4 | import haxe.macro.Context; 5 | import haxe.macro.Type; 6 | 7 | using tink.MacroApi; 8 | 9 | class Targets { 10 | static public function from(db:Expr, targetE:Expr, cnx:Expr) { 11 | return switch targetE.expr { 12 | case EObjectDecl([target]): 13 | var name = target.field; 14 | switch Context.typeof(macro @:privateAccess ${target.expr}.asSelected()) { 15 | case TInst(_, [fields, filter, result, db]): 16 | var fields = []; 17 | var fieldsComplex = ComplexType.TAnonymous(fields); 18 | var resultComplex = result.toComplex(); 19 | var aliasFields = []; 20 | switch haxe.macro.Context.followWithAbstracts(result) { 21 | case TAnonymous(_.get().fields => originalFields): 22 | for (field in originalFields) { 23 | var fComplex = field.type.toComplex(); 24 | fields.push({ 25 | pos: field.pos, 26 | name: field.name, 27 | kind: FProp('default', 'never', macro :tink.sql.Expr.Field<$fComplex, $resultComplex>) 28 | }); 29 | aliasFields.push({ 30 | field: field.name, 31 | expr: macro new tink.sql.Expr.Field($v{name}, $v{field.name}, ${typeToExprOfExprType(field.type)}), 32 | }); 33 | } 34 | default: throw "assert"; 35 | } 36 | var aliasFieldsE = EObjectDecl(aliasFields).at(target.expr.pos); 37 | var f:Function = { 38 | expr: macro return null, 39 | ret: macro :tink.sql.Expr.Condition, 40 | args: [ 41 | { 42 | name: name, 43 | type: fieldsComplex 44 | } 45 | ], 46 | } 47 | var filterType = f.asExpr().typeof().sure().toComplex({direct: true}); 48 | var blank = target.expr.pos.makeBlankType(); 49 | macro @:pos(target.expr.pos) { 50 | var query = ${target.expr}; 51 | var fields = (cast $aliasFieldsE : $fieldsComplex); 52 | @:privateAccess new tink.sql.Dataset.Selectable($cnx, fields, 53 | (TQuery($v{name}, query.toQuery()) : tink.sql.Target<$resultComplex, $blank>), 54 | function(filter:$filterType) return filter(fields)); 55 | } 56 | default: target.expr.reject('Dataset expected'); 57 | } 58 | default: targetE.reject('Object declaration with a single property expected'); 59 | } 60 | } 61 | 62 | static function typeToExprOfExprType(type:Type):ExprOf> { 63 | return switch type.getID() { 64 | case 'String': macro VString; 65 | case 'Bool': macro VBool; 66 | case 'Float': macro VFloat; 67 | case 'Int' | 'tink.sql.Id': macro VInt; 68 | case 'haxe.Int64' | 'tink.sql.Id64': macro VInt64; 69 | case 'haxe.io.Bytes': macro VBytes; 70 | case 'Date': macro VDate; 71 | case 'tink.s2d.Point': macro VGeometry(Point); 72 | case 'tink.s2d.LineString': macro VGeometry(LineString); 73 | case 'tink.s2d.Polygon': macro VGeometry(Polygon); 74 | case 'tink.s2d.MultiPoint': macro VGeometry(MultiPoint); 75 | case 'tink.s2d.MultiLineString': macro VGeometry(MultiLineString); 76 | case 'tink.s2d.MultiPolygon': macro VGeometry(MultiPolygon); 77 | case 'Array': 78 | switch type.reduce() { 79 | case TInst(_, [param]): macro VArray(${typeToExprOfExprType(param)}); 80 | case _: throw 'unreachable'; 81 | } 82 | case _: throw 'Cannot convert $type to ExprType'; 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/tink/sql/format/Statement.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.format; 2 | 3 | import haxe.Int64; 4 | import haxe.io.Bytes; 5 | 6 | private enum StatementMember { 7 | Sql(query:String); 8 | Ident(name:String); 9 | Value(value:Any); 10 | } 11 | 12 | class StatementFactory { 13 | inline public static function ident(name:String):Statement 14 | return [Ident(name)]; 15 | 16 | inline public static function value(value:Any):Statement 17 | return [Value(value)]; 18 | 19 | inline public static function sql(query:String):Statement 20 | return [Sql(query)]; 21 | 22 | inline public static function parenthesis(stmnt:Statement):Statement 23 | return empty().parenthesis(stmnt); 24 | 25 | inline public static function separated(input:Array):Statement 26 | return empty().separated(input); 27 | 28 | inline public static function empty():Statement 29 | return ([]: Statement); 30 | } 31 | 32 | @:forward(length) 33 | abstract Statement(Array) from Array to Array { 34 | static var SEPARATE = Sql(', '); 35 | static var WHITESPACE = Sql(' '); 36 | 37 | inline public function space():Statement 38 | return this.concat([WHITESPACE]); 39 | 40 | inline public function ident(name:String):Statement 41 | return this.concat([Ident(name)]); 42 | 43 | inline public function addIdent(name:String):Statement 44 | return space().ident(name); 45 | 46 | inline public function value(value:Any):Statement 47 | return this.concat([Value(value)]); 48 | 49 | inline public function addValue(value:Any):Statement 50 | return space().value(value); 51 | 52 | inline public function sql(query:String):Statement 53 | return this.concat(fromString(query)); 54 | 55 | inline public function parenthesis(stmnt:Statement):Statement 56 | return sql('(').concat(stmnt).sql(')'); 57 | 58 | inline public function addParenthesis(stmnt:Statement):Statement 59 | return space().parenthesis(stmnt); 60 | 61 | inline public function add(addition:Statement, condition = true):Statement 62 | return 63 | if (condition && addition.length > 0) space().concat(addition) 64 | else this; 65 | 66 | public function separated(input:Array):Statement { 67 | var res = this.slice(0); 68 | for (i in 0 ... input.length) { 69 | if (i > 0) res.push(SEPARATE); 70 | res = res.concat(input[i]); 71 | } 72 | return res; 73 | } 74 | 75 | inline public function addSeparated(input:Array):Statement 76 | return space().separated(input); 77 | 78 | public function concat(other:Statement):Statement 79 | return this.concat(other); 80 | 81 | @:from public static function fromString(query:String):Statement 82 | return switch query { 83 | case null | '': []; 84 | case v: [Sql(query)]; 85 | } 86 | 87 | public function toString(sanitizer:Sanitizer) { 88 | var res = new StringBuf(); 89 | for (member in this) 90 | switch member { 91 | case Sql(query): res.add(query); 92 | case Ident(ident): res.add(sanitizer.ident(ident)); 93 | case Value(v): 94 | res.add(sanitizer.value(v)); 95 | } 96 | return res.toString(); 97 | } 98 | 99 | public function prepare(ident:String->String) { 100 | var query = new StringBuf(); 101 | var values = []; 102 | for (member in this) 103 | switch member { 104 | case Sql(sql): query.add(sql); 105 | case Ident(i): query.add(ident(i)); 106 | case Value(v): 107 | query.add('?'); 108 | #if nodejs 109 | if (Std.is(v, Bytes)) v = js.node.Buffer.hxFromBytes(cast v); 110 | #end 111 | if (Int64.isInt64(v)) v = Int64.toStr(v); 112 | values.push(v); 113 | } 114 | return {query: query.toString(), values: values} 115 | } 116 | } -------------------------------------------------------------------------------- /src/tink/sql/macros/Joins.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.macros; 2 | 3 | import haxe.macro.Context; 4 | import tink.sql.Target.JoinType; 5 | import haxe.macro.Type; 6 | import haxe.macro.Expr; 7 | 8 | using haxe.macro.Tools; 9 | using tink.MacroApi; 10 | 11 | class Joins { 12 | 13 | static function getRow(e:Expr) 14 | return Context.typeof(macro @:pos(e.pos) { 15 | var source = $e, x = null; 16 | source.stream().forEach(function (y) { x = y; return tink.streams.Stream.Handled.Resume; } ); 17 | x; 18 | }); 19 | 20 | static public function perform(type:JoinType, left:Expr, right:Expr) { 21 | 22 | var rowFields = new Array(), 23 | fieldsObj #if (haxe_ver >= 4) : Array #end = []; 24 | 25 | function traverse(e:Expr, fieldsExpr:Expr, nullable:Bool) { 26 | 27 | function add(name, type, ?nested) { 28 | 29 | rowFields.push({ 30 | name: name, 31 | pos: e.pos, 32 | kind: FProp('default', 'null', type), 33 | }); 34 | 35 | fieldsObj.push({ 36 | field: name, 37 | expr: 38 | if (nested) macro $fieldsExpr.fields.$name 39 | else macro $fieldsExpr.fields 40 | }); 41 | } 42 | 43 | var parts = Filters.getArgs(e); 44 | switch parts { 45 | case [single]: 46 | 47 | add(single.name, getRow(e).toComplex()); 48 | 49 | default: 50 | switch getRow(e).reduce().toComplex() { 51 | case TAnonymous([for (f in _) f.name => f] => byName): 52 | for (p in parts) 53 | add(p.name, switch byName[p.name] { 54 | case null: e.reject('Lost track of ${p.name}'); 55 | case f: (f:Member).getVar().sure().type; 56 | }, true); 57 | default: 58 | e.reject(); 59 | } 60 | } 61 | return parts; 62 | } 63 | 64 | var total = traverse(left, macro left, type == Right); 65 | total = total.concat(traverse(right, macro right, type == Left));//need separate statements because of evaluation order 66 | 67 | var f:Function = { 68 | expr: macro return null, 69 | ret: macro : tink.sql.Expr.Condition, 70 | args: [for (a in total) { 71 | name: a.name, 72 | type: a.t.toComplex({ direct: true }), 73 | }], 74 | } 75 | 76 | var rowType = TAnonymous(rowFields); 77 | var filterType = f.asExpr().typeof().sure().toComplex( { direct: true } ); 78 | 79 | var ret = macro @:pos(left.pos) @:privateAccess { 80 | 81 | var left = $left, 82 | right = $right; 83 | 84 | function toCondition(filter:$filterType) 85 | return ${(macro filter).call([for (field in fieldsObj) field.expr])}; 86 | 87 | var ret = new tink.sql.Dataset.JoinPoint( 88 | function (cond:$filterType) return new tink.sql.Dataset.Selectable( 89 | left.cnx, 90 | ${EObjectDecl(fieldsObj).at()}, 91 | tink.sql.Target.TJoin( 92 | left.target, 93 | right.target, 94 | ${joinTypeExpr(type)}, 95 | toCondition(cond) 96 | ), 97 | toCondition, 98 | { 99 | where: left.condition.where && right.condition.where, 100 | having: left.condition.having && right.condition.having 101 | } 102 | ) 103 | ); 104 | 105 | if (false) { 106 | (ret.on(null).stream() : tink.streams.RealStream<$rowType>); 107 | } 108 | 109 | ret; 110 | 111 | } 112 | 113 | return ret; 114 | } 115 | 116 | static function joinTypeExpr(t:JoinType) 117 | return switch t { 118 | case Inner: macro Inner; 119 | case Left: macro Left; 120 | case Right: macro Right; 121 | } 122 | 123 | } 124 | -------------------------------------------------------------------------------- /src/tink/sql/drivers/node/externs/PostgreSql.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.drivers.node.externs; 2 | 3 | import js.node.events.EventEmitter; 4 | import haxe.extern.EitherType; 5 | import js.lib.Promise as JsPromise; 6 | using tink.CoreApi.JsPromiseTools; 7 | 8 | import #if haxe3 js.lib.Error #else js.Error #end as JsError; 9 | 10 | typedef TypeParsers = { 11 | function getTypeParser(dataTypeID:Int, format:String):String->Dynamic; 12 | } 13 | 14 | typedef PostgresSslConfig = haxe.extern.EitherType; 21 | 22 | typedef ClientConfig = { 23 | ?user:String, 24 | ?host:String, 25 | ?database:String, 26 | ?password:String, 27 | ?port:Int, 28 | ?connectionString:String, 29 | ?ssl:PostgresSslConfig, 30 | ?types:TypeParsers, 31 | ?statement_timeout:Int, 32 | ?query_timeout:Int, 33 | ?connectionTimeoutMillis:Int, 34 | ?idle_in_transaction_session_timeout:Int, 35 | } 36 | 37 | typedef PoolConfig = { 38 | >ClientConfig, 39 | ?connectionTimeoutMillis:Int, 40 | ?idleTimeoutMillis:Int, 41 | ?max:Int, 42 | } 43 | 44 | typedef QueryOptions = { 45 | text:String, 46 | ?values:Array, 47 | ?name:String, 48 | ?rowMode:String, 49 | ?types:TypeParsers, 50 | } 51 | 52 | typedef Submittable = { 53 | function submit(connection:Dynamic):Void; 54 | } 55 | 56 | @:jsRequire("pg") 57 | extern class Pg { 58 | static public var types(default, null):{ 59 | public function setTypeParser(oid:Int, parser:String->Dynamic):Void; 60 | public function getTypeParser(oid:Int, format:String):String->Dynamic; 61 | } 62 | } 63 | 64 | // https://node-postgres.com/api/pool 65 | @:jsRequire("pg", "Pool") 66 | extern class Pool extends EventEmitter { 67 | public function new(?config:PoolConfig):Void; 68 | public function connect():JsPromise; 69 | @:overload(function(config:QueryOptions):JsPromise{}) 70 | @:overload(function(s:S):S{}) 71 | public function query(sql:String, ?values:Dynamic):JsPromise; 72 | public function end():JsPromise; 73 | public var totalCount:Int; 74 | public var idleCount:Int; 75 | public var waitingCount:Int; 76 | } 77 | 78 | // https://node-postgres.com/api/client 79 | @:jsRequire("pg", "Client") 80 | extern class Client extends EventEmitter { 81 | public function new(?config:ClientConfig):Void; 82 | public function connect():JsPromise; 83 | @:overload(function(config:QueryOptions):JsPromise{}) 84 | @:overload(function(s:S):S{}) 85 | public function query(sql:String, ?values:Dynamic):JsPromise; 86 | public function end():JsPromise; 87 | public function release(?err:Dynamic):JsPromise; 88 | public function escapeIdentifier(str:String):String; 89 | public function escapeLiteral(str:String):String; 90 | 91 | inline static public function escapeIdentifier(str:String):String return untyped Client.prototype.escapeIdentifier(str); 92 | inline static public function escapeLiteral(str:String):String return untyped Client.prototype.escapeLiteral(str); 93 | } 94 | 95 | // https://node-postgres.com/api/result 96 | @:jsRequire("pg", "Result") 97 | extern class Result { 98 | public var rows:Array; 99 | public var fields:Array<{ 100 | name:String, 101 | }>; 102 | public var command:String; 103 | public var rowCount:Int; 104 | } 105 | 106 | #if false // not used 107 | // https://node-postgres.com/api/cursor 108 | @:jsRequire("pg-cursor") 109 | extern class Cursor extends EventEmitter { 110 | public function new(text:String, values:Dynamic, ?config:{ 111 | ?rowMode:String, 112 | ?types:TypeParsers, 113 | }):Void; 114 | public function read(rowCount:Int, callback:JsError->Array->Result->Void):Void; 115 | public function close(?cb:?JsError->Void):Void; 116 | public function submit(connection:Dynamic):Void; 117 | } 118 | #end 119 | 120 | typedef GeoJSONOptions = { 121 | ?shortCrs:Bool, 122 | ?longCrs:Bool, 123 | } 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI](https://github.com/haxetink/tink_sql/workflows/CI/badge.svg)](https://github.com/haxetink/tink_sql/actions) 2 | [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/haxetink/public) 3 | 4 | # Tinkerbell SQL 5 | 6 | This library embeds SQL right into the Haxe language. Think LINQ (not the syntax sugar, but the framework). 7 | 8 | **Breaking Change (2021-09-16)** 9 | 10 | [Transaction](#transactions) is now supported with a minor breaking change. 11 | Migration guide: https://github.com/haxetink/tink_sql/pull/130#issuecomment-822971910 12 | 13 | ## Motivation 14 | 15 | Most developers tend to dislike SQL, at least for a significant part of their career. A symptom of that are ever-repeating attempts to hide the database layer behind ORMs, with very limited success. 16 | 17 | Relational databases are however a very powerful concept, that was pioneered over 40 years ago. 18 | 19 | Tink SQL has been built to embrace using SQL for your database interactions, but in a type safe way that fits with the Haxe ecosystem. 20 | 21 | ## Defining your schema 22 | 23 | Define a database like so: 24 | 25 | ```haxe 26 | import tink.sql.Types; 27 | 28 | typedef User = { 29 | var id:Id; 30 | var name:VarChar<255>; 31 | @:unique var email:VarChar<255>; 32 | var password:VarChar<255>; 33 | } 34 | 35 | typedef Post = { 36 | var id:Id; 37 | var author:Id; 38 | var title:LongText; 39 | var url:VarChar<255>; 40 | } 41 | 42 | typedef Tag = { 43 | var id:Id; 44 | var name:VarChar<20>; 45 | var desc:Null; 46 | } 47 | 48 | typedef PostTags = { 49 | var post:Id; 50 | var tag:Id; 51 | } 52 | 53 | @:tables(User, Post, Tag, PostTags) 54 | class Db extends tink.sql.Database {} 55 | ``` 56 | 57 | ## Redefining table names 58 | 59 | ```haxe 60 | class Db extends tink.sql.Database { 61 | @:table("blog_users") var user:User; 62 | @:table("blog_posts") var post:Post; 63 | @:table("news_tags") var tag:Tag; 64 | @:table("post_tags") var postTags:PostTags; 65 | } 66 | ``` 67 | 68 | ## Connecting to the database 69 | 70 | ```haxe 71 | import tink.sql.drivers.MySql; 72 | 73 | var driver = new tink.sql.drivers.MySql({ 74 | user: 'user', 75 | password: 'pass' 76 | }); 77 | var db = new Db('db_name', driver); 78 | ``` 79 | 80 | ## Tables API 81 | 82 | 83 | - Table setup 84 | - `db.User.create(): Promise;` 85 | - `db.User.drop(): Promise;` 86 | - `db.User.truncate(): Promise;` 87 | - Selecting 88 | - `db.User.count(): Promise;` 89 | - `db.User.all(limit, orderBy): Promise>;` 90 | - `db.User.first(orderBy): Promise;` 91 | - `db.User.where(filter)` 92 | - `db.User.select(f:Fields->Selection)` 93 | - Example, select name of user: `db.User.select({name: User.name}).where(User.id == 1).first()` 94 | - Writing 95 | - `db.User.insertOne(row: User): Promise>;` 96 | - `db.User.insertMany(rows: Array): Promise>;` 97 | - `db.User.update(f:Fields->Update, options:{ where: Filter, ?max:Int }): Promise<{rowsAffected: Int}>;` 98 | - Example, rename all users called 'Dave' to 'Donald': `db.User.update(function (u) return [u.name.set('Donald')], { where: function (u) return u.name == 'Dave' } );` 99 | - `db.User.delete(options:{ where: Filter, ?max:Int }): Promise<{rowsAffected: Int}>;` 100 | - Advanced Selecting 101 | - `db.User.as(alias);` 102 | - `db.User.join(db.User).on(id == copiedFrom).all();` 103 | - `db.User.leftJoin(db.User);` 104 | - `db.User.rightJoin(db.User);` 105 | - `db.User.stream(limit, orderBy): tink.streams.RealStream;` 106 | 107 | ... to be continued ... 108 | 109 | ## Transactions 110 | 111 | ```haxe 112 | // transaction with a commit 113 | db.transaction(trx -> { 114 | trx.User.insertOne(...).next(id -> Commit(id)); // user is inserted 115 | }); 116 | 117 | // transaction with explicit rollback 118 | db.transaction(trx -> { 119 | trx.User.insertOne(...).next(_ -> Rollback); // user insert is rolled back 120 | }); 121 | 122 | // transaction with implicit rollback upon error 123 | db.transaction(trx -> { 124 | trx.User.insertOne(...).next(_ -> new Error('Aborted')); // user insert is rolled back 125 | }); 126 | ``` -------------------------------------------------------------------------------- /src/tink/sql/Types.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | import haxe.*; 4 | import tink.sql.Expr; 5 | 6 | typedef Blob<@:const L> = haxe.io.Bytes; 7 | 8 | typedef DateTime = Date; 9 | typedef Timestamp = Date; 10 | 11 | typedef TinyInt = Int; 12 | typedef SmallInt = Int; 13 | typedef MediumInt = Int; 14 | typedef BigInt = haxe.Int64; 15 | 16 | typedef Text = String; 17 | typedef LongText = String; 18 | typedef MediumText = String; 19 | typedef TinyText = String; 20 | typedef VarChar<@:const L> = String; 21 | 22 | typedef Json = T; 23 | 24 | typedef Point = tink.s2d.Point; 25 | typedef LineString = tink.s2d.LineString; 26 | typedef Polygon = tink.s2d.Polygon; 27 | typedef MultiPoint = tink.s2d.MultiPoint; 28 | typedef MultiLineString = tink.s2d.MultiLineString; 29 | typedef MultiPolygon = tink.s2d.MultiPolygon; 30 | typedef Geometry = tink.s2d.Geometry; 31 | 32 | abstract Id(Int) to Int { 33 | 34 | public inline function new(v) 35 | this = v; 36 | 37 | @:deprecated('See https://github.com/haxetink/tink_sql/pull/94') 38 | @:from static inline function ofStringly(s:tink.Stringly):Id 39 | return new Id(s); 40 | 41 | @:from static inline function ofInt(i:Int):Id 42 | return new Id(i); 43 | 44 | @:to public inline function toString() 45 | return Std.string(this); 46 | 47 | @:to public function toExpr():Expr> 48 | return tink.sql.Expr.ExprData.EValue(new Id(this), cast VInt); 49 | 50 | #if tink_json 51 | @:from static inline function ofRep(r:tink.json.Representation):Id 52 | return new Id(r.get()); 53 | 54 | @:to inline function toRep():tink.json.Representation 55 | return new tink.json.Representation(this); 56 | #end 57 | 58 | @:op(A>B) static function gt(a:Id, b:Id):Bool; 59 | @:op(A(a:Id, b:Id):Bool; 60 | @:op(A>=B) static function gte(a:Id, b:Id):Bool; 61 | @:op(A>=B) static function lte(a:Id, b:Id):Bool; 62 | @:op(A==B) static function eq(a:Id, b:Id):Bool; 63 | @:op(A!=B) static function neq(a:Id, b:Id):Bool; 64 | @:op(A+B) static function plus(a:Id, b:Int):Id; 65 | @:op(A++) inline function postfixInc():Id { 66 | return this++; 67 | } 68 | @:op(++A) inline function prefixInc():Id { 69 | return ++this; 70 | } 71 | @:op(A--) inline function postfixDec():Id { 72 | return this--; 73 | } 74 | @:op(--A) inline function prefixDec():Id { 75 | return --this; 76 | } 77 | } 78 | 79 | abstract Id64(Int64) to Int64 { 80 | 81 | public inline function new(v) 82 | this = v; 83 | 84 | @:from static inline function ofInt64(i:Int64):Id64 85 | return new Id64(i); 86 | 87 | @:to public inline function toString() 88 | return Int64.toStr(this); 89 | 90 | @:to public function toExpr():Expr> 91 | return tink.sql.Expr.ExprData.EValue(new Id64(this), cast VInt64); 92 | 93 | @:op(A>B) inline static function gt(a:Id64, b:Id64):Bool { 94 | return (a:Int64) > (b:Int64); 95 | } 96 | @:op(A(a:Id64, b:Id64):Bool { 97 | return (a:Int64) < (b:Int64); 98 | } 99 | @:op(A>=B) inline static function gte(a:Id64, b:Id64):Bool { 100 | return (a:Int64) >= (b:Int64); 101 | } 102 | @:op(A>=B) inline static function lte(a:Id64, b:Id64):Bool { 103 | return (a:Int64) <= (b:Int64); 104 | } 105 | @:op(A==B) inline static function eq(a:Id64, b:Id64):Bool { 106 | return (a:Int64) == (b:Int64); 107 | } 108 | @:op(A!=B) static function neq(a:Id64, b:Id64):Bool { 109 | return (a:Int64) != (b:Int64); 110 | } 111 | @:op(A+B) static function plus(a:Id64, b:Int64):Id64 { 112 | return (a:Int64) + b; 113 | } 114 | @:op(A-B) static function minus(a:Id64, b:Int64):Id64 { 115 | return (a:Int64) - b; 116 | } 117 | @:op(A++) inline function postfixInc():Id64 { 118 | return this++; 119 | } 120 | @:op(++A) inline function prefixInc():Id64 { 121 | return ++this; 122 | } 123 | @:op(A--) inline function postfixDec():Id64 { 124 | return this--; 125 | } 126 | @:op(--A) inline function prefixDec():Id64 { 127 | return --this; 128 | } 129 | 130 | } 131 | -------------------------------------------------------------------------------- /src/tink/sql/drivers/node/Sqlite3.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.drivers.node; 2 | 3 | import haxe.DynamicAccess; 4 | import haxe.io.Bytes; 5 | import tink.sql.Query; 6 | import tink.sql.Info; 7 | import tink.sql.Types; 8 | import tink.sql.format.Sanitizer; 9 | import tink.streams.Stream; 10 | import tink.sql.format.SqliteFormatter; 11 | import tink.sql.expr.ExprTyper; 12 | import tink.sql.parse.ResultParser; 13 | import tink.streams.RealStream; 14 | 15 | import #if haxe3 js.lib.Error #else js.Error #end as JsError; 16 | 17 | using tink.CoreApi; 18 | 19 | class Sqlite3 implements Driver { 20 | 21 | public final type:Driver.DriverType = Sqlite; 22 | 23 | var fileForName: String->String; 24 | 25 | public function new(?fileForName:String->String) 26 | this.fileForName = fileForName; 27 | 28 | public function open(name:String, info:DatabaseInfo):Connection.ConnectionPool { 29 | var cnx = new Sqlite3Database( 30 | switch fileForName { 31 | case null: name; 32 | case f: f(name); 33 | } 34 | ); 35 | return new Sqlite3Connection(info, cnx); 36 | } 37 | } 38 | 39 | class Sqlite3Connection implements Connection.ConnectionPool { 40 | 41 | var cnx:Sqlite3Database; 42 | var info:DatabaseInfo; 43 | var formatter = new SqliteFormatter(); 44 | var parser:ResultParser; 45 | 46 | public function new(info, cnx) { 47 | this.info = info; 48 | this.cnx = cnx; 49 | this.parser = new ResultParser(); 50 | } 51 | 52 | public function getFormatter() 53 | return formatter; 54 | 55 | function toError(error:JsError):Outcome 56 | return Failure(Error.withData(error.message, error)); 57 | 58 | function streamStatement( 59 | statement:PreparedStatement, 60 | parse:DynamicAccess->T 61 | ):RealStream 62 | return Generator.stream( 63 | function next(step) 64 | statement.get([], function (error, row) { 65 | step(switch [error, row] { 66 | case [null, null]: 67 | statement.finalize(); 68 | End; 69 | case [null, row]: Link(parse(row), Generator.stream(next)); 70 | case [error, _]: Fail(Error.withData(error.message, error)); 71 | }); 72 | }) 73 | ); 74 | 75 | public function execute(query:Query):Result { 76 | return switch query { 77 | case Select(_) | Union(_) | CallProcedure(_): 78 | var parse = parser.queryParser(query, formatter.isNested(query)); 79 | get(query).next(function (statement) { 80 | return streamStatement(statement, parse); 81 | }); 82 | case Transaction(_) | CreateTable(_, _) | DropTable(_) | AlterTable(_, _): 83 | run(query).next(function(_) return Noise); 84 | case TruncateTable(table): 85 | // https://sqlite.org/lang_delete.html#the_truncate_optimization 86 | run(Delete({from: table})).next(function(_) return Noise); 87 | case Insert(_): 88 | run(query).next(function(res) return new Id(res.lastID)); 89 | case Update(_) | Delete(_): 90 | run(query).next(function(res) return {rowsAffected: (res.changes: Int)}); 91 | default: 92 | throw 'Operation not supported'; 93 | } 94 | } 95 | 96 | function prepare(query:Query) 97 | return formatter.format(query).prepare( 98 | tink.sql.drivers.MySql.getSanitizer(null).ident 99 | ); 100 | 101 | function get(query:Query): Promise 102 | return Future.async(function (done) { 103 | var prepared = prepare(query); 104 | var res = null; 105 | res = cnx.prepare(prepared.query, prepared.values, function (error) done( 106 | if (error != null) toError(error) 107 | else Success(res) 108 | )); 109 | }); 110 | 111 | function all(query:Query): Promise> 112 | return Future.async(function(done) { 113 | var prepared = prepare(query); 114 | cnx.all(prepared.query, prepared.values, function(error, rows) done( 115 | if (error == null) Success(rows) 116 | else toError(error) 117 | )); 118 | }); 119 | 120 | function run(query:Query): Promise<{lastID:Int, changes:Int}> 121 | return Future.async(function(done) { 122 | var prepared = prepare(query); 123 | cnx.run(prepared.query, prepared.values, function(error) { 124 | done( 125 | if (error == null) Success(js.Syntax.code('this')) 126 | else toError(error) 127 | ); 128 | }); 129 | }); 130 | 131 | public function executeSql(sql:String): Promise { 132 | return Future.async(function(done) { 133 | cnx.exec(sql, function(error) { 134 | done( 135 | if (error == null) Success(Noise) 136 | else toError(error) 137 | ); 138 | }); 139 | }); 140 | } 141 | 142 | public function isolate():Pair, CallbackLink> { 143 | return new Pair((this:Connection), null); 144 | } 145 | } 146 | 147 | private extern class NativeSqlite3 { 148 | static var OPEN_READONLY:Int; 149 | static var OPEN_READWRITE:Int; 150 | static var OPEN_CREATE:Int; 151 | } 152 | 153 | // https://github.com/mapbox/node-sqlite3/wiki/API 154 | @:jsRequire("sqlite3", "Database") 155 | private extern class Sqlite3Database { 156 | function new(file:String, ?mode: Int, ?callback:JsError->Void):Void; 157 | function run(sql:String, values:Array, callback:JsError->Void):Void; 158 | function all(sql:String, values:Array, callback:JsError->Array->Void):Void; 159 | function prepare(sql:String, values:Array, callback:JsError->Void):PreparedStatement; 160 | function exec(sql:String, callback:JsError->Void):Void; 161 | } 162 | 163 | private extern class PreparedStatement { 164 | function get(values:Array, callback:JsError->DynamicAccess->Void):Void; 165 | function finalize():Void; 166 | } -------------------------------------------------------------------------------- /tests/SubQueryTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import tink.testrunner.Assertions; 4 | import tink.unit.Assert.assert; 5 | import tink.sql.OrderBy; 6 | import tink.sql.Types; 7 | import tink.sql.Expr; 8 | import tink.sql.Expr.Functions.*; 9 | import Db; 10 | 11 | using StringTools; 12 | using tink.CoreApi; 13 | 14 | @:asserts 15 | class SubQueryTest extends TestWithDb { 16 | 17 | @:before @:access(Run) 18 | public function before() { 19 | var run = new Run(driver, db); 20 | return Promise.inParallel([ 21 | db.Post.create(), 22 | db.User.create(), 23 | db.PostTags.create(), 24 | ]) 25 | .next(function (_) return run.insertUsers()) 26 | .next(function(_) return Promise.inSequence([ 27 | Promise.lazy(run.insertPost.bind('test', 'Alice', ['test', 'off-topic'])), 28 | Promise.lazy(run.insertPost.bind('test2', 'Alice', ['test'])), 29 | Promise.lazy(run.insertPost.bind('Some ramblings', 'Alice', ['off-topic'])), 30 | Promise.lazy(run.insertPost.bind('Just checking', 'Bob', ['test'])), 31 | ])); 32 | } 33 | 34 | @:after 35 | public function after() { 36 | return Promise.inParallel([ 37 | db.Post.drop(), 38 | db.User.drop(), 39 | db.PostTags.drop(), 40 | ]); 41 | } 42 | 43 | public function selectSubQuery() { 44 | return db.User 45 | .select({ 46 | name: User.name, 47 | posts: db.Post.select({count: count()}).where(Post.author == User.id) 48 | }) 49 | .where(User.name == 'Alice') 50 | .first() 51 | .next(function(row) { 52 | return assert(row.name == 'Alice' && row.posts == 3); 53 | }); 54 | } 55 | 56 | public function selectExpr() { 57 | return db.Post 58 | .where( 59 | Post.author == db.User.select({id: User.id}).where(Post.author == User.id && User.name == 'Bob') 60 | ).first() 61 | .next(function(row) { 62 | return assert(row.title == 'Just checking'); 63 | }); 64 | } 65 | 66 | public function inSubQuery() { 67 | return db.User 68 | .where( 69 | User.id.inArray(db.User.select({id: User.id}).where(User.name == 'Dave')) 70 | ).all() 71 | .next(function(rows) { 72 | return assert(rows.length == 2); 73 | }); 74 | } 75 | 76 | public function anyFunc():Assertions { 77 | return switch driver.type { 78 | case MySql: 79 | db.Post 80 | .where( 81 | Post.author == any(db.User.select({id: User.id})) 82 | ).first() 83 | .next(function(row) { 84 | return assert(true); 85 | }); 86 | case Sqlite | PostgreSql | CockroachDb: 87 | // syntax not supported 88 | asserts.done(); 89 | } 90 | } 91 | 92 | public function someFunc():Assertions { 93 | return switch driver.type { 94 | case MySql: 95 | db.Post 96 | .where( 97 | Post.author == some(db.User.select({id: User.id})) 98 | ).first() 99 | .next(function(row) { 100 | return assert(true); 101 | }); 102 | case Sqlite | PostgreSql | CockroachDb: 103 | // syntax not supported 104 | asserts.done(); 105 | } 106 | } 107 | 108 | public function existsFunc() { 109 | return db.Post 110 | .where( 111 | exists(db.User.where(User.id == Post.author)) 112 | ).first() 113 | .next(function(row) { 114 | return assert(true); 115 | }); 116 | } 117 | 118 | public function fromSubquery():tink.testrunner.Assertions { 119 | return db 120 | .from({myPosts: db.Post.where(Post.author == 1)}) 121 | .select({id: myPosts.id}) 122 | .first() 123 | .next(function(row) { 124 | asserts.assert(row.id == 1); 125 | return asserts.done(); 126 | }); 127 | } 128 | 129 | public function fromSimpleTable() { 130 | return db 131 | .from({myPosts: db.Post}) 132 | .select({id: myPosts.id}) 133 | .first() 134 | .next(function(row) { 135 | asserts.assert(row.id == 1); 136 | return asserts.done(); 137 | }); 138 | } 139 | 140 | public function fromComplexSubquery() { 141 | return db 142 | .from({sub: db.Post.select({maxId: max(Post.id), renamed: Post.author}).groupBy(fields -> [fields.author])}) 143 | .join(db.User).on(User.id == sub.renamed) 144 | .first() 145 | .next(function(row) { 146 | asserts.assert(row.sub.maxId == 3); 147 | asserts.assert(row.sub.renamed == 1); 148 | asserts.assert(row.User.id == 1); 149 | asserts.assert(row.User.name == 'Alice'); 150 | return asserts.done(); 151 | }); 152 | } 153 | 154 | public function fromComplexSubqueryAndFilter() { 155 | return db.User 156 | .join(db.from({sub: db.Post.select({maxId: max(Post.id), renamed: Post.author}).groupBy(fields -> [fields.author])})) 157 | .on(User.id == sub.renamed) 158 | .where(User.id == 2 && sub.maxId >= 1) 159 | .first() 160 | .next(function(row) { 161 | asserts.assert(row.sub.maxId == 4); 162 | asserts.assert(row.sub.renamed == 2); 163 | asserts.assert(row.User.id == 2); 164 | asserts.assert(row.User.name == 'Bob'); 165 | return asserts.done(); 166 | }); 167 | } 168 | 169 | public function insertSelectFromSelection() { 170 | return db.User.insertSelect(db.User.select({ 171 | id: null, 172 | name: User.name, 173 | email: User.email, 174 | location: User.location, 175 | }).where(User.id == 1)) 176 | .next(function(id) { 177 | asserts.assert(id == 6); 178 | return asserts.done(); 179 | }); 180 | } 181 | 182 | public function insertSelectFromTable() { 183 | return db.User.insertSelect(db.User.where(User.id == 1)) 184 | // TODO: find a way to dodge the DUPICATE_KEY error 185 | .map(function(o) return switch o { 186 | case Success(_): 187 | asserts.fail(new Error('should fail with a duplicate key error')); 188 | case Failure(e): 189 | switch driver.type { 190 | case MySql: 191 | asserts.assert(e.message.indexOf('Duplicate entry') != -1); 192 | case Sqlite: 193 | asserts.assert(e.message.indexOf('UNIQUE constraint failed') != -1); 194 | case PostgreSql | CockroachDb: 195 | throw "Not implemented"; 196 | } 197 | asserts.done(); 198 | }); 199 | } 200 | } -------------------------------------------------------------------------------- /src/tink/sql/drivers/php/PDO.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.drivers.php; 2 | 3 | import tink.sql.format.Formatter; 4 | import haxe.DynamicAccess; 5 | import haxe.Int64; 6 | import haxe.io.Bytes; 7 | import tink.sql.Query; 8 | import tink.sql.Info; 9 | import tink.sql.Types; 10 | import tink.sql.format.Sanitizer; 11 | import tink.streams.Stream; 12 | import tink.sql.format.MySqlFormatter; 13 | import tink.sql.format.SqliteFormatter; 14 | import tink.sql.expr.ExprTyper; 15 | import tink.sql.parse.ResultParser; 16 | import tink.sql.drivers.MySqlSettings; 17 | import php.db.PDO; 18 | import php.db.PDOStatement; 19 | import php.db.PDOException; 20 | 21 | using tink.CoreApi; 22 | 23 | class PDOMysql implements Driver { 24 | public final type:Driver.DriverType = MySql; 25 | 26 | var settings:MySqlSettings; 27 | 28 | public function new(settings) 29 | this.settings = settings; 30 | 31 | function or(value:Null, byDefault: T) 32 | return value == null ? byDefault : value; 33 | 34 | public function open(name:String, info:DatabaseInfo):Connection.ConnectionPool { 35 | return new PDOConnection( 36 | info, 37 | new MySqlFormatter(), 38 | new PDO( 39 | 'mysql:host=${or(settings.host, 'localhost')};' 40 | + 'port=${or(settings.port, 3306)};' 41 | + 'dbname=$name;charset=${or(settings.charset, 'utf8mb4')}', 42 | settings.user, 43 | settings.password 44 | ) 45 | ); 46 | } 47 | } 48 | 49 | class PDOSqlite implements Driver { 50 | public final type:Driver.DriverType = Sqlite; 51 | 52 | var fileForName: String->String; 53 | 54 | public function new(?fileForName:String->String) 55 | this.fileForName = fileForName; 56 | 57 | public function open(name:String, info:DatabaseInfo):Connection.ConnectionPool { 58 | return new PDOConnection( 59 | info, 60 | new SqliteFormatter(), 61 | new PDO( 62 | 'sqlite:' + switch fileForName { 63 | case null: name; 64 | case f: f(name); 65 | } 66 | ) 67 | ); 68 | } 69 | } 70 | 71 | class PDOConnection implements Connection.ConnectionPool implements Sanitizer { 72 | 73 | var info:DatabaseInfo; 74 | var cnx:PDO; 75 | var formatter:Formatter<{}, {}>; 76 | var parser:ResultParser; 77 | 78 | public function new(info, formatter, cnx) { 79 | this.info = info; 80 | this.cnx = cnx; 81 | cnx.setAttribute(PDO.ATTR_ERRMODE, PDO.ERRMODE_EXCEPTION); 82 | this.formatter = formatter; 83 | this.parser = new ResultParser(); 84 | } 85 | 86 | public function value(v:Any):String { 87 | if (Int64.isInt64(v)) return Int64.toStr(v); 88 | if (Std.is(v, Bool)) return v ? '1' : '0'; 89 | if (v == null || Std.is(v, Int)) return '$v'; 90 | if (Std.is(v, Date)) v = (cast v: Date).toString(); 91 | else if (Std.is(v, Bytes)) v = (cast v: Bytes).toString(); 92 | return cnx.quote(v); 93 | } 94 | 95 | public function ident(s:String):String 96 | return tink.sql.drivers.MySql.getSanitizer(null).ident(s); 97 | 98 | public function getFormatter() 99 | return formatter; 100 | 101 | public function execute(query:Query):Result { 102 | inline function fetch(): Promise 103 | return run(formatter.format(query).toString(this)); 104 | return switch query { 105 | case Select(_) | Union(_) | CallProcedure(_): 106 | Stream.promise(fetch().next(function (res:PDOStatement) { 107 | var row: Any; 108 | var parse = parser.queryParser(query, formatter.isNested(query)); 109 | return Stream.ofIterator({ 110 | hasNext: function() { 111 | row = res.fetchObject(); 112 | return row != false; 113 | }, 114 | next: function () return parse(row) 115 | }); 116 | })); 117 | case Transaction(_) | CreateTable(_, _) | DropTable(_) | AlterTable(_, _) | TruncateTable(_): 118 | fetch().next(function(_) return Noise); 119 | case Insert(_): 120 | fetch().next(function(_) return new Id(Std.parseInt(cnx.lastInsertId()))); 121 | case Update(_) | Delete(_): 122 | fetch().next(function(res) return {rowsAffected: res.rowCount()}); 123 | case ShowColumns(_): 124 | fetch().next(function(res:PDOStatement):Array 125 | return [for (row in res.fetchAll(PDO.FETCH_OBJ)) formatter.parseColumn(row)] 126 | ); 127 | case ShowIndex(_): 128 | fetch().next(function (res) return formatter.parseKeys( 129 | [for (row in res.fetchAll(PDO.FETCH_OBJ)) row] 130 | )); 131 | } 132 | } 133 | 134 | function run(query:String):Promise { 135 | #if sql_debug 136 | trace(query); 137 | #end 138 | return 139 | try cnx.query(query) 140 | catch (e: PDOException) 141 | new Error(e.getCode(), e.getMessage()); 142 | } 143 | 144 | public function executeSql(sql:String):tink.core.Promise { 145 | return Future.sync( 146 | try { 147 | cnx.exec(sql); 148 | Success(Noise); 149 | } catch (e: PDOException) { 150 | Failure(Error.withData(e.getMessage(), e)); 151 | } 152 | ); 153 | } 154 | 155 | // haxetink/tink_streams#20 156 | public function syncResult(query:Query): Outcome, Error> { 157 | return switch query { 158 | case Select(_) | Union(_) | CallProcedure(_): 159 | var parse = parser.queryParser(query, formatter.isNested(query)); 160 | var statement = formatter.format(query).toString(this); 161 | 162 | #if sql_debug 163 | trace(statement); 164 | #end 165 | 166 | try Success([ 167 | for ( 168 | row in 169 | cnx 170 | .query(statement) 171 | .fetchAll(PDO.FETCH_OBJ) 172 | ) 173 | parse(row) 174 | ]) catch (e: PDOException) 175 | Failure(new Error(e.getCode(), e.getMessage())); 176 | default: throw 'Cannot iterate this query'; 177 | } 178 | } 179 | 180 | public function isolate():Pair, CallbackLink> { 181 | return new Pair((this:Connection), null); 182 | } 183 | } 184 | -------------------------------------------------------------------------------- /tests/Db.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import tink.sql.Types; 4 | 5 | typedef User = { 6 | @:autoIncrement @:primary public var id(default, null):Id; 7 | public var name(default, null):VarChar<50>; 8 | public var email(default, null):VarChar<50>; 9 | public var location(default, null):Null>; 10 | } 11 | typedef Post = { 12 | @:autoIncrement @:primary public var id(default, null):Id; 13 | public var author(default, null):Id; 14 | public var title(default, null):VarChar<50>; 15 | public var content(default, null):VarChar<50>; 16 | } 17 | 18 | typedef PostTags = { 19 | public var post(default, null):Id; 20 | public var tag(default, null):VarChar<50>; 21 | } 22 | 23 | typedef Clap = { 24 | @:primary public var user(default, null):Id; 25 | @:primary public var post(default, null):Id; 26 | public var count(default, null):Int; 27 | } 28 | 29 | typedef Types = { 30 | public var int(default, null):Int; 31 | public var float(default, null):Float; 32 | public var text(default, null):VarChar<40>; 33 | public var blob(default, null):Blob<1000000>; 34 | public var varbinary(default, null):Blob<10000>; 35 | public var date(default, null):DateTime; 36 | public var boolTrue(default, null):Bool; 37 | public var boolFalse(default, null):Bool; 38 | 39 | @:optional public var optionalInt(default, null):Int; 40 | @:optional public var optionalText(default, null):VarChar<40>; 41 | @:optional public var optionalBlob(default, null):Blob<1000000>; 42 | @:optional public var optionalVarbinary(default, null):Blob<10000>; 43 | @:optional public var optionalDate(default, null):DateTime; 44 | @:optional public var optionalBool(default, null):Bool; 45 | 46 | public var nullInt(default, null):Null; 47 | public var nullText(default, null):Null>; 48 | public var nullBlob(default, null):Null>; 49 | public var nullVarbinary(default, null):Null>; 50 | public var nullDate(default, null):Null; 51 | public var nullBool(default, null):Null; 52 | 53 | @:optional public var abstractInt(default, null):AInt; 54 | @:optional public var abstractFloat(default, null):AFloat; 55 | @:optional public var abstractString(default, null):AString; 56 | @:optional public var abstractBool(default, null):ABool; 57 | @:optional public var abstractDate(default, null):ADate; 58 | 59 | @:optional public var enumAbstractInt(default, null):EInt; 60 | @:optional public var enumAbstractFloat(default, null):EFloat; 61 | @:optional public var enumAbstractString(default, null):EString; 62 | @:optional public var enumAbstractBool(default, null):EBool; 63 | } 64 | 65 | typedef Geometry = { 66 | public var point(default, null):Null; 67 | public var lineString(default, null):Null; 68 | public var polygon(default, null):Null; 69 | 70 | @:optional public var optionalPoint(default, null):Point; 71 | @:optional public var optionalLineString(default, null):LineString; 72 | @:optional public var optionalPolygon(default, null):Polygon; 73 | 74 | // public var multiPoint(default, null):Null; 75 | // public var multiLineString(default, null):Null; 76 | // public var multiPolygon(default, null):Null; 77 | } 78 | 79 | typedef TimestampTypes = { 80 | public var timestamp(default, null): Timestamp; 81 | } 82 | 83 | typedef Schema = { 84 | @:autoIncrement @:primary public var id(default, null):Id; 85 | 86 | public var toBoolean(default, null): Bool; 87 | public var toInt(default, null): Int; 88 | public var toFloat(default, null): Float; 89 | public var toText(default, null): VarChar<1>; 90 | public var toLongText(default, null): Text; 91 | public var toDate(default, null): DateTime; 92 | 93 | public var toAdd(default, null): Bool; 94 | 95 | @:index public var indexed(default, null): Bool; 96 | @:unique public var unique(default, null): Bool; 97 | 98 | @:index('ab') public var a(default, null): Bool; 99 | @:index('ab') public var b(default, null): Bool; 100 | @:index('cd') public var c(default, null): Bool; 101 | @:index('cd') public var d(default, null): Bool; 102 | 103 | @:unique('ef') public var e(default, null): Bool; 104 | @:unique('ef') public var f(default, null): Bool; 105 | @:unique('gh') public var g(default, null): Bool; 106 | @:unique('gh') public var h(default, null): Bool; 107 | } 108 | 109 | typedef BigIntTypes = { 110 | @:autoIncrement @:primary public var id(default, null):Id64; 111 | public var int0(default, null): BigInt; 112 | public var intMin(default, null): BigInt; 113 | public var intMax(default, null): BigInt; 114 | } 115 | 116 | typedef StringTypes = { 117 | @:autoIncrement @:primary public var id(default, null):Id; 118 | public var text10(default, null): VarChar<20>; 119 | public var text255(default, null): VarChar<255>; 120 | public var text999(default, null): VarChar<999>; 121 | public var text65536(default, null): VarChar<65536>; 122 | public var textTiny(default, null): TinyText; 123 | public var textDefault(default, null): Text; 124 | public var textMedium(default, null): MediumText; 125 | public var textLong(default, null): LongText; 126 | } 127 | 128 | typedef JsonTypes = { 129 | @:autoIncrement @:primary public var id(default, null):Id; 130 | public var jsonNull(default, null):Json<{}>; 131 | public var jsonTrue(default, null):Json; 132 | public var jsonFalse(default, null):Json; 133 | public var jsonInt(default, null):Json; 134 | public var jsonFloat(default, null):Json; 135 | public var jsonArrayInt(default, null):Json>; 136 | public var jsonObject(default, null):Json; 137 | 138 | @:optional public var jsonOptNull(default, null):Json>; 139 | } 140 | 141 | typedef Db = tink.sql.Database; 142 | @:tables(User, Post, PostTags, Clap, Types, Geometry, Schema, StringTypes, JsonTypes, BigIntTypes, TimestampTypes) 143 | interface Def extends tink.sql.DatabaseDefinition { 144 | @:procedure var func:Int->{x:Int, point:tink.s2d.Point}; 145 | @:table('alias') var PostAlias:Post; 146 | } 147 | 148 | abstract AInt(Int) from Int to Int {} 149 | abstract AFloat(Float) from Float to Float {} 150 | abstract AString(VarChar<255>) from String to String {} 151 | abstract ABool(Bool) from Bool to Bool {} 152 | abstract ADate(DateTime) from Date to Date {} 153 | 154 | @:enum abstract EInt(Int) to Int {var I = 1;} 155 | @:enum abstract EFloat(Float) to Float {var F = 1.0;} 156 | @:enum abstract EString(VarChar<255>) to String {var S = 'a';} 157 | @:enum abstract EBool(Bool) to Bool {var B = true;} 158 | -------------------------------------------------------------------------------- /Earthfile: -------------------------------------------------------------------------------- 1 | VERSION 0.6 2 | FROM mcr.microsoft.com/vscode/devcontainers/base:0-bullseye 3 | ARG WORKDIR=/workspace 4 | RUN mkdir -m 777 "/workspace" 5 | WORKDIR $WORKDIR 6 | 7 | ARG TARGETARCH 8 | 9 | ARG USERNAME=vscode 10 | ARG USER_UID=1000 11 | ARG USER_GID=$USER_UID 12 | 13 | ENV HAXESHIM_ROOT=/haxe 14 | 15 | devcontainer-library-scripts: 16 | RUN curl -fsSLO https://raw.githubusercontent.com/microsoft/vscode-dev-containers/main/script-library/common-debian.sh 17 | RUN curl -fsSLO https://raw.githubusercontent.com/microsoft/vscode-dev-containers/main/script-library/docker-debian.sh 18 | SAVE ARTIFACT --keep-ts *.sh AS LOCAL .devcontainer/library-scripts/ 19 | 20 | github-src: 21 | ARG --required REPO 22 | ARG --required COMMIT 23 | ARG DIR=/src 24 | WORKDIR $DIR 25 | RUN curl -fsSL "https://github.com/${REPO}/archive/${COMMIT}.tar.gz" | tar xz --strip-components=1 -C "$DIR" 26 | SAVE ARTIFACT "$DIR" 27 | 28 | # Usage: 29 | # COPY +earthly/earthly /usr/local/bin/ 30 | # RUN earthly bootstrap --no-buildkit --with-autocomplete 31 | earthly: 32 | FROM +devcontainer-base 33 | RUN curl -fsSL https://github.com/earthly/earthly/releases/download/v0.6.14/earthly-linux-${TARGETARCH} -o /usr/local/bin/earthly \ 34 | && chmod +x /usr/local/bin/earthly 35 | SAVE ARTIFACT /usr/local/bin/earthly 36 | 37 | devcontainer-base: 38 | # Avoid warnings by switching to noninteractive 39 | ENV DEBIAN_FRONTEND=noninteractive 40 | 41 | ARG INSTALL_ZSH="false" 42 | ARG UPGRADE_PACKAGES="true" 43 | ARG ENABLE_NONROOT_DOCKER="true" 44 | ARG USE_MOBY="false" 45 | COPY .devcontainer/library-scripts/common-debian.sh .devcontainer/library-scripts/docker-debian.sh /tmp/library-scripts/ 46 | RUN apt-get update \ 47 | && /bin/bash /tmp/library-scripts/common-debian.sh "${INSTALL_ZSH}" "${USERNAME}" "${USER_UID}" "${USER_GID}" "${UPGRADE_PACKAGES}" "true" "true" \ 48 | && /bin/bash /tmp/library-scripts/docker-debian.sh "${ENABLE_NONROOT_DOCKER}" "/var/run/docker-host.sock" "/var/run/docker.sock" "${USERNAME}" "${USE_MOBY}" \ 49 | # Clean up 50 | && apt-get autoremove -y && apt-get clean -y && rm -rf /var/lib/apt/lists/* /tmp/library-scripts/ 51 | 52 | # https://github.com/nodesource/distributions#installation-instructions 53 | RUN mkdir -p /etc/apt/keyrings && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg 54 | ARG NODE_MAJOR=16 55 | RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list 56 | 57 | # Configure apt and install packages 58 | RUN apt-get update \ 59 | && apt-get install -y --no-install-recommends apt-utils dialog 2>&1 \ 60 | && apt-get install -y \ 61 | iproute2 \ 62 | procps \ 63 | sudo \ 64 | bash-completion \ 65 | build-essential \ 66 | curl \ 67 | wget \ 68 | software-properties-common \ 69 | direnv \ 70 | tzdata \ 71 | # install docker engine for running `WITH DOCKER` 72 | docker-ce \ 73 | # for testing php target 74 | php-cli \ 75 | php-mbstring \ 76 | php-mysql \ 77 | php-sqlite3 \ 78 | nodejs="$NODE_MAJOR.*" \ 79 | # 80 | # Clean up 81 | && apt-get autoremove -y \ 82 | && apt-get clean -y \ 83 | && rm -rf /var/lib/apt/lists/* 84 | 85 | # Switch back to dialog for any ad-hoc use of apt-get 86 | ENV DEBIAN_FRONTEND= 87 | 88 | # Setting the ENTRYPOINT to docker-init.sh will configure non-root access 89 | # to the Docker socket. The script will also execute CMD as needed. 90 | ENTRYPOINT [ "/usr/local/share/docker-init.sh" ] 91 | CMD [ "sleep", "infinity" ] 92 | 93 | haxeshim-root: 94 | FROM +devcontainer-base 95 | COPY haxe_libraries haxe_libraries 96 | COPY .haxerc . 97 | RUN mkdir src 98 | RUN npx lix download 99 | SAVE ARTIFACT "$HAXESHIM_ROOT" 100 | 101 | devcontainer: 102 | FROM +devcontainer-base 103 | 104 | COPY +earthly/earthly /usr/local/bin/ 105 | RUN earthly bootstrap --no-buildkit --with-autocomplete 106 | 107 | RUN npm config --global set update-notifier false 108 | RUN npm config set prefix /usr/local 109 | RUN npm install -g lix 110 | 111 | USER $USERNAME 112 | 113 | # Config direnv 114 | COPY --chown=$USER_UID:$USER_GID .devcontainer/direnv.toml "/home/$USERNAME/.config/direnv/config.toml" 115 | RUN echo 'eval "$(direnv hook bash)"' >> ~/.bashrc 116 | 117 | # Install deps 118 | COPY +haxeshim-root/* "$HAXESHIM_ROOT" 119 | COPY .haxerc package.json package-lock.json . 120 | RUN npm i 121 | VOLUME /workspace/node_modules 122 | 123 | USER root 124 | 125 | ARG IMAGE_TAG=master 126 | SAVE IMAGE ghcr.io/haxetink/tink_sql_devcontainer:$IMAGE_TAG 127 | 128 | test-base: 129 | FROM +devcontainer 130 | COPY haxe_libraries haxe_libraries 131 | COPY src src 132 | COPY tests tests 133 | COPY haxelib.json tests.hxml . 134 | 135 | test-node: 136 | BUILD +test-node-sqlite 137 | BUILD +test-node-mysql 138 | BUILD +test-node-postgres 139 | BUILD +test-node-cockroachdb 140 | 141 | test-node-sqlite: 142 | FROM +test-base 143 | ENV TEST_DB_TYPES=Sqlite 144 | WITH DOCKER 145 | RUN npm run test node 146 | END 147 | 148 | test-node-mysql: 149 | FROM +test-base 150 | ENV TEST_DB_TYPES=MySql 151 | WITH DOCKER --compose tests/docker-compose.yml --service mysql 152 | RUN npm run test node 153 | END 154 | 155 | test-node-postgres: 156 | FROM +test-base 157 | ENV TEST_DB_TYPES=PostgreSql 158 | WITH DOCKER --compose tests/docker-compose.yml --service postgres 159 | RUN npm run test node 160 | END 161 | 162 | test-node-cockroachdb: 163 | FROM +test-base 164 | ENV TEST_DB_TYPES=CockroachDb 165 | WITH DOCKER --compose tests/docker-compose.yml --service cockroachdb 166 | RUN npm run test node 167 | END 168 | 169 | test-php: 170 | BUILD +test-php-sqlite 171 | BUILD +test-php-mysql 172 | 173 | test-php-sqlite: 174 | FROM +test-base 175 | ENV TEST_DB_TYPES=Sqlite 176 | WITH DOCKER 177 | RUN npm run test php 178 | END 179 | 180 | test-php-mysql: 181 | FROM +test-base 182 | ENV TEST_DB_TYPES=MySql 183 | WITH DOCKER --compose tests/docker-compose.yml --service mysql 184 | RUN npm run test php 185 | END 186 | -------------------------------------------------------------------------------- /src/tink/sql/format/PostgreSqlFormatter.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.format; 2 | 3 | import tink.sql.Types; 4 | import tink.sql.Info; 5 | import tink.sql.Query; 6 | import tink.sql.schema.KeyStore; 7 | import tink.sql.Expr; 8 | import tink.sql.format.SqlFormatter; 9 | import tink.sql.format.Statement.StatementFactory.*; 10 | import haxe.*; 11 | using Lambda; 12 | 13 | class PostgreSqlFormatter extends SqlFormatter { 14 | override function type(type: DataType):Statement 15 | return switch type { 16 | case DBool(d): 17 | sql('BOOLEAN').add(addDefault(d)); 18 | case DDouble(d): 19 | sql('DOUBLE PRECISION').add(addDefault(d)); 20 | 21 | // There is no unsigned data types in postgres... 22 | case DInt(Tiny, _, _, d): 23 | sql('SMALLINT').add(addDefault(d)); // There is no TINYINT in postgres 24 | case DInt(Small, _, _, d): 25 | sql('SMALLINT').add(addDefault(d)); 26 | case DInt(Medium, _, _, d): 27 | sql('MEDIUMINT').add(addDefault(d)); 28 | case DInt(Default, _, _, d): 29 | sql('INT').add(addDefault(d)); 30 | 31 | case DBlob(_): 32 | 'BYTEA'; 33 | case DDate(d): 34 | sql('DATE').add(addDefault(d)); 35 | case DDateTime(d): 36 | sql('TIMESTAMPTZ').add(addDefault(d)); 37 | 38 | // https://postgis.net/docs/manual-3.1/postgis_usage.html#Geography_Basics 39 | case DPoint: 'geometry'; 40 | case DLineString: 'geometry'; 41 | case DPolygon: 'geometry'; 42 | case DMultiPoint: 'geometry'; 43 | case DMultiLineString: 'geometry'; 44 | case DMultiPolygon: 'geometry'; 45 | 46 | case _: 47 | super.type(type); 48 | } 49 | 50 | override function expr(e:ExprData, printTableName = true):Statement 51 | return switch e { 52 | case null | EValue(null, _): 53 | super.expr(e, printTableName); 54 | case EValue(v, VGeometry(Point)): 55 | 'ST_GeomFromText(\'${v.toWkt()}\',4326)'; 56 | case EValue(v, VGeometry(LineString)): 57 | 'ST_GeomFromText(\'${v.toWkt()}\',4326)'; 58 | case EValue(v, VGeometry(Polygon)): 59 | 'ST_GeomFromText(\'${v.toWkt()}\',4326)'; 60 | case EValue(v, VGeometry(MultiPoint)): 61 | 'ST_GeomFromText(\'${v.toWkt()}\',4326)'; 62 | case EValue(v, VGeometry(MultiLineString)): 63 | 'ST_GeomFromText(\'${v.toWkt()}\',4326)'; 64 | case EValue(v, VGeometry(MultiPolygon)): 65 | 'ST_GeomFromText(\'${v.toWkt()}\',4326)'; 66 | 67 | // need to cast geography to geometry 68 | // case EField(_, _, VGeometry(_)): 69 | // super.expr(e, printTableName).concat(sql("::geometry")); 70 | 71 | case ECall("VALUES", [e], type, parenthesis): 72 | switch (e:ExprData) { 73 | case EField(_, name, type): 74 | sql('EXCLUDED.').ident(name); 75 | case _: 76 | throw "assert"; 77 | } 78 | 79 | // the functions are named differently in postgis 80 | case ECall("ST_Distance_Sphere", args, type, parenthesis): 81 | super.expr(ECall("ST_DistanceSphere", args, type, parenthesis), printTableName); 82 | 83 | // https://www.postgresql.org/docs/13/functions-conditional.html#FUNCTIONS-COALESCE-NVL-IFNULL 84 | case ECall('IFNULL', args, type, parenthesis): 85 | super.expr(ECall("COALESCE", args, type, parenthesis), printTableName); 86 | 87 | default: super.expr(e, printTableName); 88 | } 89 | 90 | override function defineColumn(column:Column):Statement 91 | return switch column.type { 92 | case DInt(Default, _, true, _): 93 | ident(column.name) 94 | .add(sql('SERIAL')); 95 | case DInt(Big, _, true, _): 96 | ident(column.name) 97 | .add(sql('BIGSERIAL')); 98 | case _: 99 | super.defineColumn(column); 100 | } 101 | 102 | static function isAutoInc(c:Column) { 103 | return c.type.match(DInt(_, _, true, _)); 104 | } 105 | 106 | override function insert(insert:InsertOperation) { 107 | switch insert.data { 108 | case Select(op) if (op.selection != null): 109 | for (c in insert.table.getColumns()) 110 | if (isAutoInc(c)) { 111 | if (op.selection[c.name] != null) 112 | throw 'Auto-inc col ${c.name} can only accept null during insert'; 113 | op.selection.remove(c.name); 114 | } 115 | case _: 116 | // pass 117 | } 118 | 119 | var statement = super.insert(insert); 120 | 121 | if (insert.ignore) { 122 | statement = statement 123 | .add('ON CONFLICT DO NOTHING'); 124 | } 125 | 126 | if (insert.update != null) { 127 | var pKeys = SqlFormatter.getPrimaryKeys(insert.table); 128 | statement = statement 129 | .add('ON CONFLICT') 130 | .addParenthesis(separated(pKeys.map(k -> ident(k.name)))) 131 | .add('DO UPDATE SET') 132 | .space() 133 | .separated(insert.update.map(function (set) { 134 | return ident(set.field.name) 135 | .add('=') 136 | .add(expr(set.expr, true)); 137 | })); 138 | } 139 | 140 | var p = SqlFormatter.getAutoIncPrimaryKeyCol(insert.table); 141 | if (p != null) { 142 | statement = statement.add(sql("RETURNING").addIdent(p.name)); 143 | } 144 | 145 | return statement; 146 | } 147 | 148 | override function insertRow(columns:Iterable, row:DynamicAccess):Statement 149 | return parenthesis( 150 | separated(columns.map( 151 | function (column):Statement 152 | return switch [row[column.name], column.type] { 153 | case [null, DInt(_, _, true, _)]: "DEFAULT"; 154 | case [null, DJson] if (row.exists(column.name)): value("null"); 155 | case [null, _]: value(null); 156 | case [v, DPoint]: 'ST_GeomFromText(\'${(v:Point).toWkt()}\',4326)'; 157 | case [v, DLineString]: 'ST_GeomFromText(\'${(v:LineString).toWkt()}\',4326)'; 158 | case [v, DPolygon]: 'ST_GeomFromText(\'${(v:Polygon).toWkt()}\',4326)'; 159 | case [v, DMultiPoint]: 'ST_GeomFromText(\'${(v:MultiPoint).toWkt()}\',4326)'; 160 | case [v, DMultiLineString]: 'ST_GeomFromText(\'${(v:MultiLineString).toWkt()}\',4326)'; 161 | case [v, DMultiPolygon]: 'ST_GeomFromText(\'${(v:MultiPolygon).toWkt()}\',4326)'; 162 | case [v, DJson]: value(haxe.Json.stringify(v)); 163 | case [v, _]: value(v); 164 | } 165 | )) 166 | ); 167 | } 168 | 169 | typedef PostgreSqlColumnInfo = { 170 | 171 | } 172 | 173 | typedef PostgreSqlKeyInfo = { 174 | 175 | } -------------------------------------------------------------------------------- /src/tink/sql/drivers/node/CockroachDb.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.drivers.node; 2 | 3 | import haxe.Int64; 4 | import js.node.stream.Readable.Readable; 5 | import haxe.DynamicAccess; 6 | import haxe.io.Bytes; 7 | import tink.sql.Query; 8 | import tink.sql.Info; 9 | import tink.sql.Types; 10 | import tink.sql.format.Sanitizer; 11 | import tink.streams.Stream; 12 | import tink.sql.format.CockroachDbFormatter; 13 | import tink.sql.drivers.node.PostgreSql; 14 | import tink.sql.drivers.node.externs.PostgreSql; 15 | 16 | import #if haxe3 js.lib.Error #else js.Error #end as JsError; 17 | 18 | using tink.CoreApi; 19 | 20 | class CockroachDb implements Driver { 21 | public final type:Driver.DriverType = CockroachDb; 22 | 23 | final settings:PostgreSqlNodeSettings; 24 | 25 | public function new(settings) { 26 | this.settings = settings; 27 | } 28 | 29 | public function open(name:String, info:DatabaseInfo):Connection.ConnectionPool { 30 | final pool = new Pool({ 31 | user: settings.user, 32 | password: settings.password, 33 | host: settings.host, 34 | port: switch (settings.port) { 35 | case null: 26257; 36 | case v: v; 37 | }, 38 | ssl: settings.ssl, 39 | max: switch settings.max { 40 | case null: 1; 41 | case v: v; 42 | }, 43 | database: name, 44 | }); 45 | 46 | return new CockroachDbConnectionPool(info, pool); 47 | } 48 | } 49 | 50 | class CockroachDbConnectionPool implements Connection.ConnectionPool { 51 | final pool:Pool; 52 | final info:DatabaseInfo; 53 | final formatter:CockroachDbFormatter; 54 | final parser:PostgreSqlResultParser; 55 | final streamBatch:Int = 50; 56 | 57 | public function new(info, pool) { 58 | this.info = info; 59 | this.pool = pool; 60 | this.formatter = new CockroachDbFormatter(); 61 | this.parser = new PostgreSqlResultParser(); 62 | } 63 | 64 | public function getFormatter() 65 | return formatter; 66 | 67 | public function execute(query:Query):Result { 68 | final cnx = getNativeConnection(); 69 | return new CockroachDbConnection(info, cnx, true).execute(query); 70 | } 71 | 72 | public function executeSql(sql:String):tink.core.Promise { 73 | final cnx = getNativeConnection(); 74 | return new CockroachDbConnection(info, cnx, true).executeSql(sql); 75 | } 76 | 77 | public function isolate():Pair, CallbackLink> { 78 | final cnx = getNativeConnection(); 79 | return new Pair( 80 | (new CockroachDbConnection(info, cnx, false):Connection), 81 | (() -> cnx.handle(o -> switch o { 82 | case Success(native): native.release(); 83 | case Failure(_): // nothing to do 84 | }):CallbackLink) 85 | ); 86 | } 87 | 88 | function getNativeConnection() { 89 | return new Promise((resolve, reject) -> { 90 | var cancelled = false; 91 | pool.connect().then( 92 | client -> { 93 | if(cancelled) 94 | client.release(); 95 | else 96 | resolve(client); 97 | }, 98 | err -> reject(Error.ofJsError(err)) 99 | ); 100 | () -> cancelled = true; // there is no mechanism to undo connect, so we set a flag and release the client as soon as it is obtained 101 | }); 102 | } 103 | } 104 | 105 | class CockroachDbConnection implements Connection implements Sanitizer { 106 | final client:Promise; 107 | final info:DatabaseInfo; 108 | final formatter:CockroachDbFormatter; 109 | final parser:PostgreSqlResultParser; 110 | final streamBatch:Int = 50; 111 | final autoRelease:Bool; 112 | 113 | public function new(info, client, autoRelease) { 114 | this.info = info; 115 | this.client = client; 116 | this.formatter = new CockroachDbFormatter(); 117 | this.parser = new PostgreSqlResultParser(); 118 | this.autoRelease = autoRelease; 119 | } 120 | 121 | public function value(v:Any):String { 122 | if (Int64.isInt64(v)) 123 | return Int64.toStr(v); 124 | if (Std.is(v, Date)) 125 | return '(${Math.round((v : Date).getTime() / 1000)})::timestamptz'; // https://github.com/cockroachdb/cockroach/issues/77591 126 | if (Std.is(v, String)) 127 | return Client.escapeLiteral(v); 128 | if (Std.is(v, Bytes)) 129 | return "'\\x" + (cast v:Bytes).toHex() + "'"; 130 | 131 | return v; 132 | } 133 | 134 | public function ident(s:String):String 135 | return Client.escapeIdentifier(s); 136 | 137 | public function getFormatter() 138 | return formatter; 139 | 140 | function toError(error:JsError):Outcome 141 | return Failure(Error.withData(error.message, error)); 142 | 143 | public function execute(query:Query):Result { 144 | inline function fetch() return run(queryOptions(query)); 145 | return switch query { 146 | case Select(_) | Union(_): 147 | final parse:DynamicAccess->{} = parser.queryParser(query, formatter.isNested(query)); 148 | stream(queryOptions(query)).map(parse); 149 | case Insert(_): 150 | fetch().next(res -> res.rows.length > 0 ? Promise.resolve(new Id(res.rows[0][0])) : (Promise.NOISE:Promise)); 151 | case Update(_): 152 | fetch().next(res -> {rowsAffected: res.rowCount}); 153 | case Delete(_): 154 | fetch().next(res -> {rowsAffected: res.rowCount}); 155 | case Transaction(_) | CreateTable(_, _) | DropTable(_) | AlterTable(_, _) | TruncateTable(_): 156 | fetch().next(r -> Noise); 157 | case _: 158 | throw query.getName() + " has not been implemented"; 159 | } 160 | } 161 | 162 | function queryOptions(query:Query): QueryOptions { 163 | final sql = formatter.format(query).toString(this); 164 | #if sql_debug 165 | trace(sql); 166 | #end 167 | return switch query { 168 | case Insert(_): 169 | {text: sql, rowMode: "array"}; 170 | default: 171 | {text: sql}; 172 | } 173 | } 174 | 175 | 176 | function stream(options: QueryOptions):Stream { 177 | // TODO: use the 'row' event for streaming 178 | return client.next( 179 | client -> client.query(options) 180 | .toPromise() 181 | // don't use `Stream.ofIterator`, which may cause a `RangeError: Maximum call stack size exceeded` for large results 182 | .next(r -> Stream.ofNodeStream(r.command, Readable.from(cast r.rows), {onEnd: autoRelease ? () -> client.release() : null})) 183 | ); 184 | } 185 | 186 | function run(options: QueryOptions):Promise 187 | return client.next( 188 | client -> client.query(options) 189 | .toPromise() 190 | .asFuture() 191 | .withSideEffect(_ -> if(autoRelease) client.release()) 192 | ); 193 | 194 | public function executeSql(sql:String):tink.core.Promise { 195 | return run({text: sql}); 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/tink/sql/format/CockroachDbFormatter.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.format; 2 | 3 | import tink.sql.Types; 4 | import tink.sql.Info; 5 | import tink.sql.Query; 6 | import tink.sql.schema.KeyStore; 7 | import tink.sql.Expr; 8 | import tink.sql.format.SqlFormatter; 9 | import tink.sql.format.Statement.StatementFactory.*; 10 | import haxe.*; 11 | using Lambda; 12 | 13 | class CockroachDbFormatter extends SqlFormatter { 14 | override function type(type: DataType):Statement 15 | return switch type { 16 | case DBool(d): 17 | sql('BOOLEAN').add(addDefault(d)); 18 | case DDouble(d): 19 | sql('DOUBLE PRECISION').add(addDefault(d)); 20 | 21 | // There is no unsigned data types in postgres... 22 | case DInt(Tiny, _, _, d): 23 | sql('SMALLINT').add(addDefault(d)); // There is no TINYINT in postgres 24 | case DInt(Small, _, _, d): 25 | sql('SMALLINT').add(addDefault(d)); 26 | case DInt(Medium | Default, _, _, d): 27 | sql('INT4').add(addDefault(d)); 28 | 29 | case DBlob(_): 30 | 'BYTEA'; 31 | case DDate(d): 32 | sql('DATE').add(addDefault(d)); 33 | case DDateTime(d): 34 | sql('TIMESTAMPTZ').add(addDefault(d)); 35 | 36 | // https://postgis.net/docs/manual-3.1/postgis_usage.html#Geography_Basics 37 | case DPoint: 'geometry'; 38 | case DLineString: 'geometry'; 39 | case DPolygon: 'geometry'; 40 | case DMultiPoint: 'geometry'; 41 | case DMultiLineString: 'geometry'; 42 | case DMultiPolygon: 'geometry'; 43 | 44 | case _: 45 | super.type(type); 46 | } 47 | 48 | override function expr(e:ExprData, printTableName = true):Statement 49 | return switch e { 50 | case null | EValue(null, _): 51 | super.expr(e, printTableName); 52 | case EValue(v, VGeometry(Point)): 53 | 'ST_GeomFromText(\'${v.toWkt()}\',4326)'; 54 | case EValue(v, VGeometry(LineString)): 55 | 'ST_GeomFromText(\'${v.toWkt()}\',4326)'; 56 | case EValue(v, VGeometry(Polygon)): 57 | 'ST_GeomFromText(\'${v.toWkt()}\',4326)'; 58 | case EValue(v, VGeometry(MultiPoint)): 59 | 'ST_GeomFromText(\'${v.toWkt()}\',4326)'; 60 | case EValue(v, VGeometry(MultiLineString)): 61 | 'ST_GeomFromText(\'${v.toWkt()}\',4326)'; 62 | case EValue(v, VGeometry(MultiPolygon)): 63 | 'ST_GeomFromText(\'${v.toWkt()}\',4326)'; 64 | 65 | // need to cast geography to geometry 66 | // case EField(_, _, VGeometry(_)): 67 | // super.expr(e, printTableName).concat(sql("::geometry")); 68 | 69 | case ECall("VALUES", [e], type, parenthesis): 70 | switch (e:ExprData) { 71 | case EField(_, name, type): 72 | sql('EXCLUDED.').ident(name); 73 | case _: 74 | throw "assert"; 75 | } 76 | 77 | // the functions are named differently in postgis 78 | case ECall("ST_Distance_Sphere", args, type, parenthesis): 79 | super.expr(ECall("ST_DistanceSphere", args, type, parenthesis), printTableName); 80 | 81 | // https://www.postgresql.org/docs/13/functions-conditional.html#FUNCTIONS-COALESCE-NVL-IFNULL 82 | case ECall('IFNULL', args, type, parenthesis): 83 | super.expr(ECall("COALESCE", args, type, parenthesis), printTableName); 84 | 85 | default: super.expr(e, printTableName); 86 | } 87 | 88 | override function createTable(table:TableInfo, ifNotExists:Bool) 89 | return sql('SET serial_normalization=sql_sequence; CREATE TABLE') 90 | .add('IF NOT EXISTS', ifNotExists) 91 | .addIdent(table.getName()) 92 | .addParenthesis( 93 | separated( 94 | table.getColumns() 95 | .map(defineColumn) 96 | .concat( 97 | table.getKeys().map(defineKey)) 98 | ) 99 | ); 100 | 101 | override function defineColumn(column:Column):Statement 102 | return switch column.type { 103 | case DInt(Default, _, true, _): 104 | ident(column.name) 105 | .add(sql('SERIAL4')); 106 | case DInt(Big, _, true, _): 107 | ident(column.name) 108 | .add(sql('SERIAL8')); 109 | case _: 110 | super.defineColumn(column); 111 | } 112 | 113 | static function isAutoInc(c:Column) { 114 | return c.type.match(DInt(_, _, true, _)); 115 | } 116 | 117 | override function insert(insert:InsertOperation) { 118 | switch insert.data { 119 | case Select(op) if (op.selection != null): 120 | for (c in insert.table.getColumns()) 121 | if (isAutoInc(c)) { 122 | if (op.selection[c.name] != null) 123 | throw 'Auto-inc col ${c.name} can only accept null during insert'; 124 | op.selection.remove(c.name); 125 | } 126 | case _: 127 | // pass 128 | } 129 | 130 | var statement = super.insert(insert); 131 | 132 | if (insert.ignore) { 133 | statement = statement 134 | .add('ON CONFLICT DO NOTHING'); 135 | } 136 | 137 | if (insert.update != null) { 138 | var pKeys = SqlFormatter.getPrimaryKeys(insert.table); 139 | statement = statement 140 | .add('ON CONFLICT') 141 | .addParenthesis(separated(pKeys.map(k -> ident(k.name)))) 142 | .add('DO UPDATE SET') 143 | .space() 144 | .separated(insert.update.map(function (set) { 145 | return ident(set.field.name) 146 | .add('=') 147 | .add(expr(set.expr, true)); 148 | })); 149 | } 150 | 151 | var p = SqlFormatter.getAutoIncPrimaryKeyCol(insert.table); 152 | if (p != null) { 153 | statement = statement.add(sql("RETURNING").addIdent(p.name)); 154 | } 155 | 156 | return statement; 157 | } 158 | 159 | override function insertRow(columns:Iterable, row:DynamicAccess):Statement 160 | return parenthesis( 161 | separated(columns.map( 162 | function (column):Statement 163 | return switch [row[column.name], column.type] { 164 | case [null, DInt(_, _, true, _)]: "DEFAULT"; 165 | case [null, DJson] if (row.exists(column.name)): value("null"); 166 | case [null, _]: value(null); 167 | case [v, DPoint]: 'ST_GeomFromText(\'${(v:Point).toWkt()}\',4326)'; 168 | case [v, DLineString]: 'ST_GeomFromText(\'${(v:LineString).toWkt()}\',4326)'; 169 | case [v, DPolygon]: 'ST_GeomFromText(\'${(v:Polygon).toWkt()}\',4326)'; 170 | case [v, DMultiPoint]: 'ST_GeomFromText(\'${(v:MultiPoint).toWkt()}\',4326)'; 171 | case [v, DMultiLineString]: 'ST_GeomFromText(\'${(v:MultiLineString).toWkt()}\',4326)'; 172 | case [v, DMultiPolygon]: 'ST_GeomFromText(\'${(v:MultiPolygon).toWkt()}\',4326)'; 173 | case [v, DJson]: value(haxe.Json.stringify(v)); 174 | case [v, _]: value(v); 175 | } 176 | )) 177 | ); 178 | } 179 | 180 | typedef CockroachDbColumnInfo = { 181 | 182 | } 183 | 184 | typedef CockroachDbKeyInfo = { 185 | 186 | } -------------------------------------------------------------------------------- /tests/FormatTest.hx: -------------------------------------------------------------------------------- 1 | package; 2 | 3 | import tink.sql.Info; 4 | import tink.sql.Types; 5 | import tink.sql.format.Sanitizer; 6 | import tink.sql.format.SqlFormatter; 7 | import tink.unit.Assert.assert; 8 | import tink.sql.drivers.MySql; 9 | import tink.sql.Database; 10 | 11 | using tink.CoreApi; 12 | 13 | @:allow(tink.unit) 14 | @:asserts 15 | @:access(tink.sql.format.SqlFormatter) 16 | class FormatTest extends TestWithDb { 17 | 18 | var uniqueDb:UniqueDb; 19 | var sanitizer:Sanitizer; 20 | var formatter:SqlFormatter<{}, {}>; 21 | 22 | public function new(driver, db) { 23 | super(driver, db); 24 | uniqueDb = new UniqueDb('test', driver); 25 | sanitizer = MySql.getSanitizer(null); 26 | formatter = new SqlFormatter(); 27 | } 28 | 29 | @:variant(new FormatTest.FakeTable1(), 'CREATE TABLE `fake` (`id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL, `admin` TINYINT NOT NULL, `age` INT UNSIGNED NULL)') 30 | @:variant(this.db.User.info, 'CREATE TABLE `User` (`email` VARCHAR(50) NOT NULL, `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `location` VARCHAR(32) NULL, `name` VARCHAR(50) NOT NULL, PRIMARY KEY (`id`))') 31 | @:variant(this.db.Types.info, 'CREATE TABLE `Types` (`abstractBool` TINYINT NULL, `abstractDate` DATETIME NULL, `abstractFloat` DOUBLE NULL, `abstractInt` INT NULL, `abstractString` VARCHAR(255) NULL, `blob` BLOB NOT NULL, `boolFalse` TINYINT NOT NULL, `boolTrue` TINYINT NOT NULL, `date` DATETIME NOT NULL, `enumAbstractBool` TINYINT NULL, `enumAbstractFloat` DOUBLE NULL, `enumAbstractInt` INT NULL, `enumAbstractString` VARCHAR(255) NULL, `float` DOUBLE NOT NULL, `int` INT NOT NULL, `nullBlob` BLOB NULL, `nullBool` TINYINT NULL, `nullDate` DATETIME NULL, `nullInt` INT NULL, `nullText` VARCHAR(40) NULL, `nullVarbinary` VARBINARY(10000) NULL, `optionalBlob` BLOB NULL, `optionalBool` TINYINT NULL, `optionalDate` DATETIME NULL, `optionalInt` INT NULL, `optionalText` VARCHAR(40) NULL, `optionalVarbinary` VARBINARY(10000) NULL, `text` VARCHAR(40) NOT NULL, `varbinary` VARBINARY(10000) NOT NULL)') 32 | @:variant(this.uniqueDb.UniqueTable.info, 'CREATE TABLE `UniqueTable` (`u1` VARCHAR(123) NOT NULL, `u2` VARCHAR(123) NOT NULL, `u3` VARCHAR(123) NOT NULL, UNIQUE KEY `u1` (`u1`), UNIQUE KEY `index_name1` (`u2`, `u3`))') 33 | public function createTable(table:TableInfo, sql:String) { 34 | // TODO: should separate out the sanitizer 35 | return assert(formatter.createTable(table, false).toString(sanitizer) == sql); 36 | } 37 | 38 | // TODO: this test is MySQL-specific 39 | // @:variant(true, 'INSERT IGNORE INTO `PostTags` (`post`, `tag`) VALUES (1, "haxe")') 40 | // @:variant(false, 'INSERT INTO `PostTags` (`post`, `tag`) VALUES (1, "haxe")') 41 | // public function insertIgnore(ignore, result) { 42 | // return assert(formatter.insert({ 43 | // table: db.PostTags.info, 44 | // data: Literal([{post: 1, tag: 'haxe'}]), 45 | // ignore: ignore 46 | // }).toString(sanitizer) == result); 47 | // } 48 | 49 | public function like() { 50 | var dataset = db.Types.where(Types.text.like('mystring')); 51 | return assert(formatter.select({ 52 | from: @:privateAccess dataset.target, 53 | where: @:privateAccess dataset.condition.where 54 | }).toString(sanitizer) == 'SELECT * FROM `Types` WHERE (`Types`.`text` LIKE "mystring")'); 55 | } 56 | 57 | public function inArray() { 58 | var dataset = db.Types.where(Types.int.inArray([1, 2, 3])); 59 | return assert(formatter.select({ 60 | from: @:privateAccess dataset.target, 61 | where: @:privateAccess dataset.condition.where 62 | }).toString(sanitizer) == 'SELECT * FROM `Types` WHERE (`Types`.`int` IN (1, 2, 3))'); 63 | } 64 | 65 | public function inEmptyArray() { 66 | var dataset = db.Types.where(Types.int.inArray([])); 67 | return assert(formatter.select({ 68 | from: @:privateAccess dataset.target, 69 | where: @:privateAccess dataset.condition.where 70 | }).toString(sanitizer) == 'SELECT * FROM `Types` WHERE false'); 71 | } 72 | 73 | @:asserts public function transaction() { 74 | asserts.assert(formatter.transaction(Start) == 'START TRANSACTION'); 75 | asserts.assert(formatter.transaction(Rollback) == 'ROLLBACK'); 76 | asserts.assert(formatter.transaction(Commit) == 'COMMIT'); 77 | return asserts.done(); 78 | } 79 | 80 | public function tableAlias() { 81 | var dataset = db.Types.as('alias'); 82 | return assert(formatter.select({ 83 | from: @:privateAccess dataset.target, 84 | where: @:privateAccess dataset.condition.where 85 | }).toString(sanitizer) == 'SELECT * FROM `Types` AS `alias`'); 86 | } 87 | 88 | // Fields are prefixed here now 89 | /*public function tableAliasJoin() { 90 | var dataset = db.Types.leftJoin(db.Types.as('alias')).on(Types.int == alias.int); 91 | return assert(formatter.select({ 92 | from: @:privateAccess dataset.target, 93 | where: @:privateAccess dataset.condition.where 94 | }) == 'SELECT * FROM `Types` LEFT JOIN `Types` AS `alias` ON (`Types`.`int` = `alias`.`int`)'); 95 | }*/ 96 | 97 | public function orderBy() { 98 | var dataset = db.Types; 99 | return assert(formatter.select({ 100 | from: @:privateAccess dataset.target, 101 | where: @:privateAccess dataset.condition.where, 102 | limit: {limit: 1, offset: 0}, 103 | orderBy: [{field: db.Types.fields.int, order: Desc}] 104 | }).toString(sanitizer) == 'SELECT * FROM `Types` ORDER BY `Types`.`int` DESC LIMIT 1'); 105 | } 106 | 107 | // https://github.com/haxetink/tink_sql/issues/10 108 | // public function compareNull() { 109 | // var dataset = db.Types.where(Types.optionalInt == null); 110 | // return assert(Format.select(@:privateAccess dataset.target, @:privateAccess dataset.condition, sanitizer) == 'SELECT * FROM `Types` WHERE `Types`.`optionalInt` = NULL'); 111 | // } 112 | } 113 | 114 | class FakeTable1 extends FakeTable { 115 | 116 | override function getName():String 117 | return 'fake'; 118 | 119 | override function getColumns():Iterable 120 | return [ 121 | {name: 'id', nullable: false, writable: true, type: DInt(Default, false, true)}, 122 | {name: 'username', nullable: false, writable: true, type: DString(50)}, 123 | {name: 'admin', nullable: false, writable: true, type: DBool()}, 124 | {name: 'age', nullable: true, writable: true, type: DInt(Default, false, false)}, 125 | ]; 126 | } 127 | 128 | class FakeTable implements TableInfo { 129 | public function new() {} 130 | 131 | public function getName():String 132 | throw 'abstract'; 133 | 134 | public function getAlias():String 135 | throw 'abstract'; 136 | 137 | public function getColumns():Iterable 138 | throw 'abstract'; 139 | 140 | public function columnNames():Iterable 141 | return [for(f in getColumns()) f.name]; 142 | 143 | public function getKeys():Iterable 144 | return []; 145 | } 146 | 147 | typedef UniqueDb = Database; 148 | interface UniqueDef extends tink.sql.DatabaseDefinition { 149 | @:table var UniqueTable:UniqueTable; 150 | } 151 | 152 | typedef UniqueTable = { 153 | @:unique var u1(default, null):VarChar<123>; 154 | @:unique('index_name1') var u2(default, null):VarChar<123>; 155 | @:unique('index_name1') var u3(default, null):VarChar<123>; 156 | } 157 | -------------------------------------------------------------------------------- /src/tink/sql/Table.hx: -------------------------------------------------------------------------------- 1 | package tink.sql; 2 | 3 | import tink.core.Any; 4 | import tink.sql.Expr; 5 | import tink.sql.Info; 6 | import tink.sql.Schema; 7 | import tink.sql.Dataset; 8 | import tink.sql.Query; 9 | import tink.sql.Types; 10 | 11 | using tink.CoreApi; 12 | 13 | #if macro 14 | import haxe.macro.Expr; 15 | using haxe.macro.Tools; 16 | using tink.MacroApi; 17 | #else 18 | @:genericBuild(tink.sql.macros.TableBuilder.build()) 19 | class Table { 20 | } 21 | #end 22 | 23 | class TableSourceCondition), Row:{}, IdType, Db> 24 | extends Selectable 25 | { 26 | 27 | public var name(default, null):TableName; 28 | var alias:Null; 29 | public final info:TableInstanceInfo; 30 | 31 | function new(cnx, name, alias, fields, info) { 32 | this.name = name; 33 | this.alias = alias; 34 | this.fields = fields; 35 | this.info = info; 36 | super( 37 | cnx, 38 | fields, 39 | TTable(info), 40 | function (f:Filter) return (cast f : Fields->Condition)(fields) //TODO: raise issue on Haxe tracker and remove the cast once resolved 41 | ); 42 | } 43 | 44 | // Query 45 | 46 | public function create(ifNotExists = false) 47 | return cnx.execute(CreateTable(info, ifNotExists)); 48 | 49 | public function drop() 50 | return cnx.execute(DropTable(info)); 51 | 52 | public function truncate() 53 | return cnx.execute(TruncateTable(info)); 54 | 55 | public function diffSchema(destructive = false) { 56 | var schema = new Schema(info.getColumns(), info.getKeys()); 57 | return (cnx.execute(ShowColumns(info)) && cnx.execute(ShowIndex(info))) 58 | .next(function(res) 59 | return new Schema(res.a, res.b) 60 | .diff(schema, cnx.getFormatter()) 61 | .filter(function (change) 62 | return destructive || !change.match(DropColumn(_)) 63 | ) 64 | ); 65 | } 66 | 67 | public function updateSchema(changes: Array) { 68 | var pre = [], post = []; 69 | for (i in 0 ... changes.length) 70 | switch changes[i] { 71 | case AddKey(_): post = changes.slice(i); break; 72 | case v: pre.push(v); 73 | } 74 | return cnx.execute(AlterTable(info, pre)).next(function(_) 75 | return 76 | if (post.length > 0) cnx.execute(AlterTable(info, post)) 77 | else Noise 78 | ); 79 | } 80 | 81 | public function insertMany(rows:Array, ?options): Promise 82 | return if (rows.length == 0) cast Promise #if (tink_core >= "2") .NOISE #else .NULL #end 83 | else insert(Literal(rows), options); 84 | 85 | public function insertOne(row:Row, ?options): Promise 86 | return insert(Literal([row]), options); 87 | 88 | public function insertSelect(selected:Selected, ?options): Promise 89 | return insert(Select(selected.toSelectOp()), options); 90 | 91 | function insert(data, ?options:{?ignore:Bool, ?replace:Bool, ?update:Fields->Update}): Promise { 92 | return cnx.execute(Insert({ 93 | table: info, 94 | data: data, 95 | ignore: options != null && !!options.ignore, 96 | replace: options != null && !!options.replace, 97 | update: options != null && options.update != null ? options.update(this.fields) : null, 98 | })); 99 | } 100 | 101 | public function update(f:Fields->Update, options:{ where: Filter, ?max:Int }) 102 | return switch f(this.fields) { 103 | case []: 104 | Promise.lift({rowsAffected: 0}); 105 | case patch: 106 | cnx.execute(Update({ 107 | table: info, 108 | set: patch, 109 | where: toCondition(options.where), 110 | max: options.max 111 | })); 112 | } 113 | 114 | public function delete(options:{ where: Filter, ?max:Int }) 115 | return cnx.execute(Delete({ 116 | from: info, 117 | where: toCondition(options.where), 118 | max: options.max 119 | })); 120 | 121 | // Alias 122 | 123 | macro public function as(e:Expr, alias:String) { 124 | return switch haxe.macro.Context.typeof(e) { 125 | case TInst(_.get() => { pack: pack, name: name, superClass: _.params => [fields, _, row, idType, _] }, _): 126 | var fieldsType = fields.toComplex({direct: true}); 127 | var filterType = (macro function ($alias:$fieldsType):tink.sql.Expr.Condition return tink.sql.Expr.ExprData.EValue(true, tink.sql.Expr.ExprType.VBool)).typeof().sure(); 128 | var path: haxe.macro.TypePath = 129 | 'tink.sql.Table.TableSource'.asTypePath( 130 | [fields, filterType, row, idType].map(function (type) 131 | return TPType(type.toComplex({direct: true})) 132 | ).concat([TPType(e.pos.makeBlankType())]) 133 | ); 134 | var aliasFields = []; 135 | switch haxe.macro.Context.follow(fields) { 136 | case TAnonymous(_.get().fields => originalFields): 137 | for (field in originalFields) { 138 | var name = field.name; 139 | aliasFields.push({ 140 | field: field.name, 141 | expr: macro new tink.sql.Expr.Field($v{alias}, $v{field.name}, $e.fields.$name.type) 142 | }); 143 | } 144 | default: throw "assert"; 145 | } 146 | var fieldObj = EObjectDecl(aliasFields).at(e.pos); 147 | macro @:privateAccess new $path($e.cnx, $e.name, $v{alias}, $fieldObj, @:privateAccess ${tink.sql.macros.Helper.typePathToExpr({pack: pack, name: name}, e.pos)}.makeInfo($e.name, $v{alias})); 148 | default: e.reject(); 149 | } 150 | } 151 | 152 | @:noCompletion 153 | macro public function init(e:Expr, rest:Array) { 154 | return switch e.typeof().sure().follow() { 155 | case TInst(_.get() => { module: m, name: n }, _): 156 | e.assign('$m.$n'.instantiate(rest)); 157 | default: e.reject(); 158 | } 159 | } 160 | 161 | } 162 | 163 | abstract TableName(String) to String { 164 | public inline function new(s) 165 | this = s; 166 | } 167 | 168 | 169 | 170 | class TableStaticInfo { 171 | 172 | final columns:Array; 173 | final keys:Array; 174 | 175 | public function new(columns, keys) { 176 | this.columns = columns; 177 | this.keys = keys; 178 | } 179 | 180 | @:noCompletion 181 | public function getColumns():Array 182 | return columns; 183 | 184 | @:noCompletion 185 | public function columnNames():Array 186 | return [for(c in columns) c.name]; 187 | 188 | @:noCompletion 189 | public function getKeys():Array 190 | return keys; 191 | } 192 | 193 | class TableInstanceInfo extends TableStaticInfo implements TableInfo { 194 | final name:String; 195 | final alias:String; 196 | 197 | public function new(name, alias, columns, keys) { 198 | super(columns, keys); 199 | this.name = name; 200 | this.alias = alias; 201 | } 202 | 203 | // TableInfo 204 | 205 | @:noCompletion 206 | public function getName():String 207 | return name; 208 | 209 | @:noCompletion 210 | public function getAlias():Null 211 | return alias; 212 | } -------------------------------------------------------------------------------- /src/tink/sql/drivers/node/PostgreSql.hx: -------------------------------------------------------------------------------- 1 | package tink.sql.drivers.node; 2 | 3 | import haxe.Int64; 4 | import js.node.stream.Readable.Readable; 5 | import haxe.DynamicAccess; 6 | import haxe.io.Bytes; 7 | import tink.sql.Query; 8 | import tink.sql.Info; 9 | import tink.sql.Types; 10 | import tink.sql.Expr; 11 | import tink.sql.format.Sanitizer; 12 | import tink.streams.Stream; 13 | import tink.sql.format.PostgreSqlFormatter; 14 | import tink.sql.parse.ResultParser; 15 | using tink.CoreApi.JsPromiseTools; 16 | import tink.sql.drivers.node.externs.PostgreSql; 17 | 18 | import #if haxe3 js.lib.Error #else js.Error #end as JsError; 19 | 20 | using tink.CoreApi; 21 | 22 | typedef PostgreSqlNodeSettings = { 23 | > PostgreSqlSettings, 24 | ?ssl:PostgresSslConfig, 25 | ?max:Int, 26 | } 27 | 28 | class PostgreSql implements Driver { 29 | 30 | public final type:Driver.DriverType = PostgreSql; 31 | 32 | final settings:PostgreSqlNodeSettings; 33 | 34 | public function new(settings) { 35 | this.settings = settings; 36 | } 37 | 38 | public function open(name:String, info:DatabaseInfo):Connection.ConnectionPool { 39 | final pool = new Pool({ 40 | user: settings.user, 41 | password: settings.password, 42 | host: settings.host, 43 | port: settings.port, 44 | ssl: settings.ssl, 45 | max: switch settings.max { 46 | case null: 1; 47 | case v: v; 48 | }, 49 | database: name, 50 | }); 51 | 52 | return new PostgreSqlConnectionPool(info, pool); 53 | } 54 | } 55 | 56 | class PostgreSqlResultParser extends ResultParser { 57 | override function parseGeometryValue(bytes:Bytes):Any { 58 | return switch tink.spatial.Parser.ewkb(bytes).geometry { 59 | case S2D(Point(v)): v; 60 | case S2D(LineString(v)): v; 61 | case S2D(Polygon(v)): v; 62 | case S2D(MultiPoint(v)): v; 63 | case S2D(MultiLineString(v)): v; 64 | case S2D(MultiPolygon(v)): v; 65 | case S2D(GeometryCollection(v)): v; 66 | case v: throw 'expected 2d geometries'; 67 | } 68 | } 69 | 70 | override function parseValue(value:Dynamic, type:ExprType): Any { 71 | if (value == null) return null; 72 | return switch type { 73 | case null: super.parseValue(value, type); 74 | case ExprType.VGeometry(_): parseGeometryValue(Bytes.ofHex(value)); 75 | case ExprType.VJson: value; 76 | default: super.parseValue(value, type); 77 | } 78 | } 79 | 80 | static inline function geoJsonToTink(geoJson:Dynamic):Dynamic { 81 | return geoJson.coordinates; 82 | } 83 | } 84 | 85 | class PostgreSqlConnectionPool implements Connection.ConnectionPool { 86 | final pool:Pool; 87 | final info:DatabaseInfo; 88 | final formatter:PostgreSqlFormatter; 89 | final parser:PostgreSqlResultParser; 90 | final streamBatch:Int = 50; 91 | 92 | public function new(info, pool) { 93 | this.info = info; 94 | this.pool = pool; 95 | this.formatter = new PostgreSqlFormatter(); 96 | this.parser = new PostgreSqlResultParser(); 97 | } 98 | 99 | public function getFormatter() 100 | return formatter; 101 | 102 | public function execute(query:Query):Result { 103 | final cnx = getNativeConnection(); 104 | return new PostgreSqlConnection(info, cnx, true).execute(query); 105 | } 106 | 107 | public function executeSql(sql:String):tink.core.Promise { 108 | final cnx = getNativeConnection(); 109 | return new PostgreSqlConnection(info, cnx, true).executeSql(sql); 110 | } 111 | 112 | public function isolate():Pair, CallbackLink> { 113 | final cnx = getNativeConnection(); 114 | return new Pair( 115 | (new PostgreSqlConnection(info, cnx, false):Connection), 116 | (() -> cnx.handle(o -> switch o { 117 | case Success(native): native.release(); 118 | case Failure(_): // nothing to do 119 | }):CallbackLink) 120 | ); 121 | } 122 | 123 | function getNativeConnection() { 124 | return new Promise((resolve, reject) -> { 125 | var cancelled = false; 126 | pool.connect().then( 127 | client -> { 128 | if(cancelled) 129 | client.release(); 130 | else 131 | resolve(client); 132 | }, 133 | err -> reject(Error.ofJsError(err)) 134 | ); 135 | () -> cancelled = true; // there is no mechanism to undo connect, so we set a flag and release the client as soon as it is obtained 136 | }); 137 | } 138 | } 139 | class PostgreSqlConnection implements Connection implements Sanitizer { 140 | final client:Promise; 141 | final info:DatabaseInfo; 142 | final formatter:PostgreSqlFormatter; 143 | final parser:PostgreSqlResultParser; 144 | final streamBatch:Int = 50; 145 | final autoRelease:Bool; 146 | 147 | public function new(info, client, autoRelease) { 148 | this.info = info; 149 | this.client = client; 150 | this.formatter = new PostgreSqlFormatter(); 151 | this.parser = new PostgreSqlResultParser(); 152 | this.autoRelease = autoRelease; 153 | } 154 | 155 | public function value(v:Any):String { 156 | if (Int64.isInt64(v)) 157 | return Int64.toStr(v); 158 | if (Std.is(v, Date)) 159 | return 'to_timestamp(${(v:Date).getTime()/1000})'; 160 | if (Std.is(v, String)) 161 | return Client.escapeLiteral(v); 162 | if (Std.is(v, Bytes)) 163 | return "'\\x" + (cast v:Bytes).toHex() + "'"; 164 | 165 | return v; 166 | } 167 | 168 | public function ident(s:String):String 169 | return Client.escapeIdentifier(s); 170 | 171 | public function getFormatter() 172 | return formatter; 173 | 174 | function toError(error:JsError):Outcome 175 | return Failure(Error.withData(error.message, error)); 176 | 177 | public function execute(query:Query):Result { 178 | inline function fetch() return run(queryOptions(query)); 179 | return switch query { 180 | case Select(_) | Union(_): 181 | final parse:DynamicAccess->{} = parser.queryParser(query, formatter.isNested(query)); 182 | stream(queryOptions(query)).map(parse); 183 | case Insert(_): 184 | fetch().next(res -> res.rows.length > 0 ? Promise.resolve(new Id(res.rows[0][0])) : (Promise.NOISE:Promise)); 185 | case Update(_): 186 | fetch().next(res -> {rowsAffected: res.rowCount}); 187 | case Delete(_): 188 | fetch().next(res -> {rowsAffected: res.rowCount}); 189 | case Transaction(_) | CreateTable(_, _) | DropTable(_) | AlterTable(_, _) | TruncateTable(_): 190 | fetch().next(r -> Noise); 191 | case _: 192 | throw query.getName() + " has not been implemented"; 193 | } 194 | } 195 | 196 | function queryOptions(query:Query): QueryOptions { 197 | final sql = formatter.format(query).toString(this); 198 | #if sql_debug 199 | trace(sql); 200 | #end 201 | return switch query { 202 | case Insert(_): 203 | {text: sql, rowMode: "array"}; 204 | default: 205 | {text: sql}; 206 | } 207 | } 208 | 209 | 210 | function stream(options: QueryOptions):Stream { 211 | // TODO: use the 'row' event for streaming 212 | return client.next( 213 | client -> client.query(options) 214 | .toPromise() 215 | // don't use `Stream.ofIterator`, which may cause a `RangeError: Maximum call stack size exceeded` for large results 216 | .next(r -> Stream.ofNodeStream(r.command, Readable.from(cast r.rows), {onEnd: autoRelease ? () -> client.release() : null})) 217 | ); 218 | } 219 | 220 | function run(options: QueryOptions):Promise 221 | return client.next( 222 | client -> client.query(options) 223 | .toPromise() 224 | .asFuture() 225 | .withSideEffect(_ -> if(autoRelease) client.release()) 226 | ); 227 | 228 | public function executeSql(sql:String):tink.core.Promise { 229 | return run({text: sql}); 230 | } 231 | } 232 | --------------------------------------------------------------------------------