From 226b92c3ff82403e52a719369569e68a88a6b814 Mon Sep 17 00:00:00 2001 From: Vlad Faust Date: Sat, 15 Sep 2018 19:05:23 +0300 Subject: [PATCH] overhaul Meet new Core rewritten from scratch! Featuring new schema declaration syntax, type-safe querying, spec splitting and overall code reduction! Removed functionality: - Validations have been removed. Use external shards instead if needed. Closes #46 - Repository `#insert`, `#update`, `#delete` and all query other than `#query` methods are removed in favour of type safety, thus closing #47 and closing #61 and also closing #33 - Converters concept has been liquidated, types now rely on `#to_db` and `#from_rs` methods via monkey patching, which closes #60 New schema declaration syntax closes #58 and closes #52. Rewritten Query resolves #48. The overhaul itself closes #55 as well Hope you guys enjoy it! --- .editorconfig | 2 + .gitignore | 3 +- .travis.yml | 8 +- LICENSE | 2 +- README.md | 122 ++++--- bench.cr | 1 + bench/bench_helper.cr | 9 + bench/logger_bench.cr | 39 +++ bench/query_bench.cr | 127 +++++++ db_spec/pg/migration.sql | 36 ++ db_spec/pg/pg_spec.cr | 1 + db_spec/pg/repository/exec_spec.cr | 37 ++ db_spec/pg/repository/query_spec.cr | 132 +++++++ db_spec/pg/repository/scalar_spec.cr | 26 ++ db_spec/repository_spec.cr | 9 + db_spec/spec_helper.cr | 2 + shard.yml | 10 +- spec/converters/enum_array_spec.cr | 26 -- spec/converters/enum_spec.cr | 25 -- spec/converters/pg/numeric_spec.cr | 20 -- spec/logger/dummy_spec.cr | 9 + spec/logger/io_spec.cr | 11 + spec/logger/standard_spec.cr | 12 + spec/migration.sql | 49 --- spec/models.cr | 68 ++++ spec/params_spec.cr | 31 -- spec/query/delete_spec.cr | 15 + spec/query/group_by_spec.cr | 23 +- spec/query/having_spec.cr | 275 +++++---------- spec/query/insert_spec.cr | 29 ++ spec/query/join_spec.cr | 130 ++----- spec/query/limit_spec.cr | 28 +- spec/query/offset_spec.cr | 28 +- spec/query/order_by_spec.cr | 28 +- spec/query/select_spec.cr | 46 +-- spec/query/set_spec.cr | 77 ----- spec/query/update_spec.cr | 16 + spec/query/where_spec.cr | 325 ++++++++---------- spec/query_spec.cr | 217 +----------- spec/repository/exec_spec.cr | 36 ++ spec/repository/mock_db.cr | 68 ++++ spec/repository/query_spec.cr | 27 ++ spec/repository/scalar_spec.cr | 36 ++ spec/repository_spec.cr | 315 ----------------- spec/schema/changes_spec.cr | 81 ++--- spec/schema/fields_spec.cr | 73 ---- spec/schema/query_shortcuts_spec.cr | 85 +++++ spec/schema/references_spec.cr | 122 ------- spec/schema_spec.cr | 2 - spec/spec_helper.cr | 3 +- spec/validation_spec.cr | 134 -------- src/core.cr | 4 +- src/core/converter.cr | 16 - src/core/converters/enum.cr | 40 --- src/core/converters/enum_array.cr | 43 --- src/core/converters/pg/numeric.cr | 37 -- src/core/ext/db/default.cr | 5 + src/core/ext/db_any.cr | 15 + src/core/ext/enum.cr | 6 + src/core/ext/enumerable.cr | 21 ++ src/core/ext/hash.cr | 6 + src/core/ext/json/serializable.cr | 8 + .../pg/result_set/json/read_serializable.cr | 15 + src/core/ext/pg/result_set/read_array.cr | 38 ++ src/core/ext/pg/result_set/read_enum.cr | 10 + src/core/ext/pg/result_set/read_hash.cr | 10 + src/core/ext/pg/result_set/read_raw.cr | 26 ++ src/core/ext/pg/result_set/read_uri.cr | 13 + src/core/ext/pg/result_set/read_uuid.cr | 13 + src/core/ext/uri.cr | 8 + src/core/ext/uuid.cr | 8 + src/core/logger.cr | 4 +- src/core/logger/dummy.cr | 7 +- src/core/logger/io.cr | 44 +-- src/core/logger/standard.cr | 48 +-- src/core/params.cr | 58 ---- src/core/primary_key.cr | 3 - src/core/query.cr | 223 +++++++----- src/core/query/group_by.cr | 19 + src/core/query/having.cr | 149 ++++++++ src/core/query/insert.cr | 196 +++++++++++ src/core/query/instance.cr | 142 -------- src/core/query/instance/group_by.cr | 20 -- src/core/query/instance/having.cr | 172 --------- src/core/query/instance/join.cr | 213 ------------ src/core/query/instance/limit.cr | 20 -- src/core/query/instance/offset.cr | 20 -- src/core/query/instance/order_by.cr | 46 --- src/core/query/instance/select.cr | 55 --- src/core/query/instance/set.cr | 63 ---- src/core/query/instance/where.cr | 172 --------- src/core/query/instance/wherish.cr | 60 ---- src/core/query/join.cr | 128 +++++++ src/core/query/limit.cr | 18 + src/core/query/offset.cr | 18 + src/core/query/order_by.cr | 55 +++ src/core/query/returning.cr | 34 ++ src/core/query/select.cr | 35 ++ src/core/query/set.cr | 222 ++++++++++++ src/core/query/where.cr | 304 ++++++++++++++++ src/core/repository.cr | 131 ++++--- src/core/repository/delete.cr | 48 --- src/core/repository/exec.cr | 50 ++- src/core/repository/insert.cr | 52 --- src/core/repository/query.cr | 119 ++----- src/core/repository/scalar.cr | 46 ++- src/core/repository/update.cr | 44 --- src/core/schema.cr | 140 +++++--- src/core/schema/changes.cr | 38 +- src/core/schema/db_mapping.cr | 133 +++++++ src/core/schema/declaration.cr | 198 +++++++++++ src/core/schema/fields.cr | 111 ------ src/core/schema/getters.cr | 73 ---- src/core/schema/initializer.cr | 35 +- src/core/schema/mapping.cr | 109 ------ src/core/schema/query_enums.cr | 96 ++++++ src/core/schema/query_shortcuts.cr | 40 +++ src/core/schema/references.cr | 105 ------ src/core/validation.cr | 176 ---------- src/ext/array.cr | 33 -- 120 files changed, 3631 insertions(+), 4072 deletions(-) create mode 100644 bench.cr create mode 100644 bench/bench_helper.cr create mode 100644 bench/logger_bench.cr create mode 100644 bench/query_bench.cr create mode 100644 db_spec/pg/migration.sql create mode 100644 db_spec/pg/pg_spec.cr create mode 100644 db_spec/pg/repository/exec_spec.cr create mode 100644 db_spec/pg/repository/query_spec.cr create mode 100644 db_spec/pg/repository/scalar_spec.cr create mode 100644 db_spec/repository_spec.cr create mode 100644 db_spec/spec_helper.cr delete mode 100644 spec/converters/enum_array_spec.cr delete mode 100644 spec/converters/enum_spec.cr delete mode 100644 spec/converters/pg/numeric_spec.cr create mode 100644 spec/logger/dummy_spec.cr create mode 100644 spec/logger/io_spec.cr create mode 100644 spec/logger/standard_spec.cr delete mode 100644 spec/migration.sql create mode 100644 spec/models.cr delete mode 100644 spec/params_spec.cr create mode 100644 spec/query/delete_spec.cr create mode 100644 spec/query/insert_spec.cr delete mode 100644 spec/query/set_spec.cr create mode 100644 spec/query/update_spec.cr create mode 100644 spec/repository/exec_spec.cr create mode 100644 spec/repository/mock_db.cr create mode 100644 spec/repository/query_spec.cr create mode 100644 spec/repository/scalar_spec.cr delete mode 100644 spec/repository_spec.cr delete mode 100644 spec/schema/fields_spec.cr create mode 100644 spec/schema/query_shortcuts_spec.cr delete mode 100644 spec/schema/references_spec.cr delete mode 100644 spec/schema_spec.cr delete mode 100644 spec/validation_spec.cr delete mode 100644 src/core/converter.cr delete mode 100644 src/core/converters/enum.cr delete mode 100644 src/core/converters/enum_array.cr delete mode 100644 src/core/converters/pg/numeric.cr create mode 100644 src/core/ext/db/default.cr create mode 100644 src/core/ext/db_any.cr create mode 100644 src/core/ext/enum.cr create mode 100644 src/core/ext/enumerable.cr create mode 100644 src/core/ext/hash.cr create mode 100644 src/core/ext/json/serializable.cr create mode 100644 src/core/ext/pg/result_set/json/read_serializable.cr create mode 100644 src/core/ext/pg/result_set/read_array.cr create mode 100644 src/core/ext/pg/result_set/read_enum.cr create mode 100644 src/core/ext/pg/result_set/read_hash.cr create mode 100644 src/core/ext/pg/result_set/read_raw.cr create mode 100644 src/core/ext/pg/result_set/read_uri.cr create mode 100644 src/core/ext/pg/result_set/read_uuid.cr create mode 100644 src/core/ext/uri.cr create mode 100644 src/core/ext/uuid.cr delete mode 100644 src/core/params.cr delete mode 100644 src/core/primary_key.cr create mode 100644 src/core/query/group_by.cr create mode 100644 src/core/query/having.cr create mode 100644 src/core/query/insert.cr delete mode 100644 src/core/query/instance.cr delete mode 100644 src/core/query/instance/group_by.cr delete mode 100644 src/core/query/instance/having.cr delete mode 100644 src/core/query/instance/join.cr delete mode 100644 src/core/query/instance/limit.cr delete mode 100644 src/core/query/instance/offset.cr delete mode 100644 src/core/query/instance/order_by.cr delete mode 100644 src/core/query/instance/select.cr delete mode 100644 src/core/query/instance/set.cr delete mode 100644 src/core/query/instance/where.cr delete mode 100644 src/core/query/instance/wherish.cr create mode 100644 src/core/query/join.cr create mode 100644 src/core/query/limit.cr create mode 100644 src/core/query/offset.cr create mode 100644 src/core/query/order_by.cr create mode 100644 src/core/query/returning.cr create mode 100644 src/core/query/select.cr create mode 100644 src/core/query/set.cr create mode 100644 src/core/query/where.cr delete mode 100644 src/core/repository/delete.cr delete mode 100644 src/core/repository/insert.cr delete mode 100644 src/core/repository/update.cr create mode 100644 src/core/schema/db_mapping.cr create mode 100644 src/core/schema/declaration.cr delete mode 100644 src/core/schema/fields.cr delete mode 100644 src/core/schema/getters.cr delete mode 100644 src/core/schema/mapping.cr create mode 100644 src/core/schema/query_enums.cr create mode 100644 src/core/schema/query_shortcuts.cr delete mode 100644 src/core/schema/references.cr delete mode 100644 src/core/validation.cr delete mode 100644 src/ext/array.cr diff --git a/.editorconfig b/.editorconfig index 8f0c87a..163eb75 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,3 +1,5 @@ +root = true + [*.cr] charset = utf-8 end_of_line = lf diff --git a/.gitignore b/.gitignore index 23ec656..e29dae7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,8 @@ -/doc/ +/docs/ /lib/ /bin/ /.shards/ +*.dwarf # Libraries don't need dependency lock # Dependencies will be locked in application that uses them diff --git a/.travis.yml b/.travis.yml index 1b78279..7c48e84 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,10 @@ language: crystal +crystal: nightly services: - postgresql script: - crystal spec + - env POSTGRESQL_URL=$POSTGRESQL_URL crystal spec db_spec - crystal docs deploy: provider: pages @@ -12,7 +14,7 @@ deploy: branch: master local_dir: docs before_script: - - psql -c 'create database core_test;' -U postgres - - psql $DATABASE_URL < spec/migration.sql + - psql -c 'create database test;' -U postgres + - psql $POSTGRESQL_URL < spec/migration.sql env: - - DATABASE_URL=postgres://postgres@localhost:5432/core_test + - POSTGRESQL_URL=postgres://postgres@localhost:5432/test diff --git a/LICENSE b/LICENSE index 65bd1d3..ceefb33 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2017 Vlad Faust +Copyright (c) 2017-2018 Vlad Faust Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index c8dbb43..e330b98 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,36 @@ -# ![Core](https://user-images.githubusercontent.com/7955682/40578252-6f1929b2-6119-11e8-9348-81505cec939f.png) +> ⚠️ Master branch requires Crystal master to compile. See [installation instructions for Crystal](https://crystal-lang.org/docs/installation/from_source_repository.html). + +![Core](https://user-images.githubusercontent.com/7955682/40578252-6f1929b2-6119-11e8-9348-81505cec939f.png) + +Type-safe and expressive SQL ORM for [Crystal](https://crystal-lang.org). [![Built with Crystal](https://img.shields.io/badge/built%20with-crystal-000000.svg?style=flat-square)](https://crystal-lang.org/) [![Build status](https://img.shields.io/travis/vladfaust/core/master.svg?style=flat-square)](https://travis-ci.org/vladfaust/core) [![Docs](https://img.shields.io/badge/docs-available-brightgreen.svg?style=flat-square)](https://github.vladfaust.com/core) [![Releases](https://img.shields.io/github/release/vladfaust/core.svg?style=flat-square)](https://github.com/vladfaust/core/releases) -[![Awesome](https://img.shields.io/badge/style-awesome-lightgrey.svg?longCache=true&style=flat-square&label=&colorA=fc60a8&colorB=494368&status=ok&logo=data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PHN2ZyAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiAgIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyIgICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgICB4bWxuczpzb2RpcG9kaT0iaHR0cDovL3NvZGlwb2RpLnNvdXJjZWZvcmdlLm5ldC9EVEQvc29kaXBvZGktMC5kdGQiICAgeG1sbnM6aW5rc2NhcGU9Imh0dHA6Ly93d3cuaW5rc2NhcGUub3JnL25hbWVzcGFjZXMvaW5rc2NhcGUiICAgd2lkdGg9IjE1NC43ODEyNW1tIiAgIGhlaWdodD0iODAuMTE1ODI5bW0iICAgdmlld0JveD0iMCAwIDE1NC43ODEyNSA4MC4xMTU4MjkiICAgdmVyc2lvbj0iMS4xIiAgIGlkPSJzdmc4IiAgIGlua3NjYXBlOnZlcnNpb249IjAuOTIuMSByMTUzNzEiICAgc29kaXBvZGk6ZG9jbmFtZT0iYXdlc29tZS5zdmciPiAgPGRlZnMgICAgIGlkPSJkZWZzMiIgLz4gIDxzb2RpcG9kaTpuYW1lZHZpZXcgICAgIGlkPSJiYXNlIiAgICAgcGFnZWNvbG9yPSIjZmZmZmZmIiAgICAgYm9yZGVyY29sb3I9IiM2NjY2NjYiICAgICBib3JkZXJvcGFjaXR5PSIxLjAiICAgICBpbmtzY2FwZTpwYWdlb3BhY2l0eT0iMC4wIiAgICAgaW5rc2NhcGU6cGFnZXNoYWRvdz0iMiIgICAgIGlua3NjYXBlOnpvb209IjAuNyIgICAgIGlua3NjYXBlOmN4PSIxMzMuMTU2NTYiICAgICBpbmtzY2FwZTpjeT0iMTAxLjUzNjMiICAgICBpbmtzY2FwZTpkb2N1bWVudC11bml0cz0ibW0iICAgICBpbmtzY2FwZTpjdXJyZW50LWxheWVyPSJsYXllcjEiICAgICBzaG93Z3JpZD0iZmFsc2UiICAgICBmaXQtbWFyZ2luLXRvcD0iMCIgICAgIGZpdC1tYXJnaW4tbGVmdD0iMCIgICAgIGZpdC1tYXJnaW4tcmlnaHQ9IjAiICAgICBmaXQtbWFyZ2luLWJvdHRvbT0iMCIgICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkyMCIgICAgIGlua3NjYXBlOndpbmRvdy1oZWlnaHQ9IjEwMTciICAgICBpbmtzY2FwZTp3aW5kb3cteD0iLTgiICAgICBpbmtzY2FwZTp3aW5kb3cteT0iLTgiICAgICBpbmtzY2FwZTp3aW5kb3ctbWF4aW1pemVkPSIxIiAvPiAgPG1ldGFkYXRhICAgICBpZD0ibWV0YWRhdGE1Ij4gICAgPHJkZjpSREY+ICAgICAgPGNjOldvcmsgICAgICAgICByZGY6YWJvdXQ9IiI+ICAgICAgICA8ZGM6Zm9ybWF0PmltYWdlL3N2Zyt4bWw8L2RjOmZvcm1hdD4gICAgICAgIDxkYzp0eXBlICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPiAgICAgICAgPGRjOnRpdGxlPjwvZGM6dGl0bGU+ICAgICAgPC9jYzpXb3JrPiAgICA8L3JkZjpSREY+ICA8L21ldGFkYXRhPiAgPGcgICAgIGlua3NjYXBlOmxhYmVsPSJMYXllciAxIiAgICAgaW5rc2NhcGU6Z3JvdXBtb2RlPSJsYXllciIgICAgIGlkPSJsYXllcjEiICAgICB0cmFuc2Zvcm09InRyYW5zbGF0ZSgtMzIuMjA5MjQzLC05OS4zODI3MDcpIj4gICAgPHBhdGggICAgICAgc3R5bGU9ImZpbGw6I2ZmZmZmZjtzdHJva2Utd2lkdGg6MC4yNjQ1ODMzMiIgICAgICAgaW5rc2NhcGU6Y29ubmVjdG9yLWN1cnZhdHVyZT0iMCIgICAgICAgZD0ibSAxODYuOTkwNDksMTM1LjgxNTgzIC0zOS42ODc1LC0zNi40MDY2NjQgLTUuNTgyNzEsNi4wODU0MTQgMzMuMDcyOTIsMzAuMzIxMjUgSCA0NC40MzI5OTQgTCA3Ny41MDU5MSwxMDUuNDY4MTIgNzEuOTIzMjAyLDk5LjM4MjcwNyAzMi4yMzU3MDMsMTM1LjgxNTgzIGggLTAuMDI2NDYgdiAyMy45OTc3MSBjIDAsMTAuODQ3OTEgMTAuNDUxMDQxLDE5LjY4NSAyMy4yODMzMzIsMTkuNjg1IGggMjQuNDczOTU4IGMgMTIuODMyMjkyLDAgMjMuMjgzMzM3LC04LjgzNzA5IDIzLjI4MzMzNywtMTkuNjg1IHYgLTE1Ljc2OTE3IGggMTIuNyB2IDE1Ljc2OTE3IGMgMCwxMC44NDc5MSAxMC40NTEwNCwxOS42ODUgMjMuMjgzMzMsMTkuNjg1IGggMjQuNDczOTYgYyAxMi44MzIyOSwwIDIzLjI4MzMzLC04LjgzNzA5IDIzLjI4MzMzLC0xOS42ODUgeiIgICAgICAgaWQ9InBhdGg0NDg3IiAvPiAgPC9nPjwvc3ZnPg==)](https://github.com/veelenga/awesome-crystal) +[![Gitter Chat](https://img.shields.io/badge/style-chat-ed1965.svg?longCache=true&style=flat-square&label=&logo=gitter-white&colorA=555)](https://gitter.im/core-orm/Lobby) +[![Awesome](https://github.com/vladfaust/awesome/blob/badge-flat-alternative/media/badge-flat-alternative.svg)](https://github.com/veelenga/awesome-crystal) [![vladfaust.com](https://img.shields.io/badge/style-.com-lightgrey.svg?longCache=true&style=flat-square&label=vladfaust&colorB=0a83d8)](https://vladfaust.com) -Core is an expressive modular ORM for [Crystal](https://crystal-lang.org) featuring: - -- ⚡️ **Efficiency** based on [Crystal](https://crystal-lang.org) performance -- ✨ **Expressiveness** with powerful DSL and lesser code -- 💼 **Safety** with strictly typed attributes - ## About -Core does not follow Active Record pattern, it's more like a data-mapping solution. There is a concept of Repository, which is basically a gateway to the database. For example: +Core is a [crystal-db](https://github.com/crystal-lang/crystal-db) ORM which does not follow Active Record pattern, it's more like a data-mapping solution. There is a concept of Repository, which is basically a gateway to the database. For example: ```crystal repo = Core::Repository.new(db) -users = repo.query(User, "SELECT * FROM users WHERE id > 42") -users.class # => Array(User) +users = repo.query(User.where(id: 42)).first +users.class # => User ``` Core also has a plently of features, including: -- Expressive Query builder, either standalone or module, allowing to use constructions like `Post.join(:author).where(author: user)`, which turns into a plain SQL -- References preloader (the example above would return a `Post` which has `#author = ` attribute) -- Validations module allowing to perform both inline and custom validations (`user.valid? # => true`) +- Expressive and **type-safe** Query builder, allowing to use constructions like `Post.join(:author).where(author: user)`, which turns into a plain SQL +- References preloader (the example above would return a `Post` which has `#author = ` attribute set) +- Beautiful schema definition syntax + +However, Core is designed to be minimal, so it doesn't perform tasks you may got used to, for example, it doesn't do database migrations itself. You may use [migrate](https://github.com/vladfaust/migrate.cr) instead. Also its Query builder is not intended to fully replace SQL but instead to help a developer to write less and safer code. -However, Core is designed to be minimal, so it doesn't perform task you may got used to, for example, it doesn't do database migrations itself. You may use [migrate](https://github.com/vladfaust/migrate.cr) instead. +Also note that although Core code is designed to be abstract sutiable for any [crystal-db](https://github.com/crystal-lang/crystal-db) driver, it currently works with PostgreSQL only. But it's fairly easy to implement other drivers like MySQL or SQLite (see `/src/core/ext/pg` and `/src/core/repository.cr`). ## Installation @@ -39,88 +40,99 @@ Add this to your application's `shard.yml`: dependencies: core: github: vladfaust/core - version: ~> 0.4.2 + version: ~> 0.5.0 ``` This shard follows [Semantic Versioning v2.0.0](http://semver.org/), so check [releases](https://github.com/vladfaust/core/releases) and change the `version` accordingly. ## Basic example -Assuming following database schema: +Assuming following database migration: ```sql CREATE TABLE users( - id SERIAL PRIMARY KEY, - name VARCHAR(100) NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL, + age INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE posts( - id SERIAL PRIMARY KEY, - author_id INT NOT NULL REFERENCES users (id), - content TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ + id SERIAL PRIMARY KEY, + author_uuid INT NOT NULL REFERENCES users (uuid), + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ ); ``` +Crystal code: + ```crystal +require "pg" require "core" -require "pg" # Or maybe another driver class User include Core::Schema - include Core::Query - include Core::Validations - schema :users do - primary_key :id - reference :posts, Array(Post), foreign_key: :author_id + schema users do + pkey uuid : UUID # UUIDs are supported out of the box + + type name : String # Has NOT NULL in the column definition + type age : Union(Int32 | Nil) # Does not have NULL in the column definition + type created_at : Time = DB::Default # Has DEFAULT in the column definition - field :name, String, validate: {size: (3..100)} - field :created_at, Time, db_default: true # Means that DB is handling the default value + type posts : Array(Post), foreign_key: "author_uuid" # That is an implicit reference end end class Post include Core::Schema - include Core::Query - include Core::Validations - schema :posts do - primary_key :id - reference :author, User, key: :author_id + schema posts do + pkey id : Int32 + + type author : User, key: "author_id" # That is an explicit reference + type content : String - field :content, String - field :created_at, Time, db_default: true - field :updated_at, Time? + type created_at : Time = DB::Default + type updated_at : Union(Time | Nil) end end -db = DB.open(ENV["DATABASE_URL"]) query_logger = Core::Logger::IO.new(STDOUT) -repo = Core::Repository.new(db, query_logger) +repo = Core::Repository.new(DB.open(ENV["DATABASE_URL"]), query_logger) + +# Most of the query builder methods (e.g. insert) are type-safe +user = repo.query(User.insert(name: "Vlad")).first +post = repo.query(Post.insert(author: user, content: "What a beauteful day!")).first # Oops + +# Logging to STDOUT: +# [postgresql] INSERT INTO posts (author_uuid, content) VALUES (?, ?) RETURNING * +# 1.708ms +# [map] Post +# 126μs -user = User.new(name: "Vl") -user.valid? # => false -user.errors # => [{:name => "must have size in range of 3..100"}] -user.name = "Vlad" -user = repo.insert(user) +# #to_s returns raw SQL string, and for superiour performance you can store them in constants +QUERY = Post.update.set(content: "placeholder").where(id: 0).to_s +# UPDATE posts SET content = ? WHERE (id = ?) -post = repo.insert(Post.new(author: user, content: "What a beauteful day!")) # Oops +# Would not return anything, however, doesn't check for incoming params types +repo.exec(QUERY, "What a beautiful day!", post.id) -post.content = "What a beautiful day!" -repo.update(post) +# Join with preloading references +posts = repo.query(Post.select('*').where(author: user).join(:author, select: {'*'})) -posts = repo.query(Post.where(author: user).join(:author)) puts posts.first.inspect -# => @content="What a beautiful day!"> +# => @content="What a beautiful day!"> ``` ## Testing -1. Apply migration from `./spec/migration.sql` -2. Run `env DATABASE_URL=your_database_url crystal spec` +1. Run generic specs with `crystal spec` +2. Apply migrations from `./db_spec/*/migration.sql` +3. Run DB-specific specs with `env POSTGRESQL_URL=postgres://postgres:postgres@localhost:5432/core crystal spec db_spec` +4. Optionally run benchmarks with `crystal bench.cr --release` ## Contributing diff --git a/bench.cr b/bench.cr new file mode 100644 index 0000000..b7ded3e --- /dev/null +++ b/bench.cr @@ -0,0 +1 @@ +require "./bench/**" diff --git a/bench/bench_helper.cr b/bench/bench_helper.cr new file mode 100644 index 0000000..e4eab8f --- /dev/null +++ b/bench/bench_helper.cr @@ -0,0 +1,9 @@ +require "benchmark" +require "colorize" +require "../spec/models" + +COLORS = { + header: :yellow, + subheader: :blue, + success: :green, +} diff --git a/bench/logger_bench.cr b/bench/logger_bench.cr new file mode 100644 index 0000000..cada8a7 --- /dev/null +++ b/bench/logger_bench.cr @@ -0,0 +1,39 @@ +require "./bench_helper" + +puts "\nRunning Core::Logger benchmarks...\n".colorize(COLORS["header"]) + +def devnull + File.open(File::DEVNULL, mode: "w") +end + +logger = Logger.new(devnull, Logger::DEBUG) + +elapsed = Time.measure do + Benchmark.ips do |x| + io = Core::Logger::IO.new(devnull) + + x.report "io w/ colors" do + io.wrap("foo") { nil } + end + + io = Core::Logger::IO.new(devnull, false) + + x.report "io w/o colors" do + io.wrap("foo") { nil } + end + + std_logger = Core::Logger::Standard.new(logger, Logger::Severity::INFO) + + x.report "logger w/ colors" do + std_logger.wrap("foo") { nil } + end + + std_logger = Core::Logger::Standard.new(logger, Logger::Severity::INFO, false) + + x.report "logger w/o colors" do + std_logger.wrap("foo") { nil } + end + end +end + +puts "\nCompleted in #{TimeFormat.auto(elapsed)} ✔️".colorize(COLORS["success"]) diff --git a/bench/query_bench.cr b/bench/query_bench.cr new file mode 100644 index 0000000..c24a193 --- /dev/null +++ b/bench/query_bench.cr @@ -0,0 +1,127 @@ +require "./bench_helper" + +puts "\nRunning Query benchmarks...".colorize(COLORS["header"]) + +elapsed = Time.measure do + {% for building in [false, true] %} + puts "\n> with#{{{building ? "" : "out"}}} building\n".colorize(COLORS["subheader"]) + + Benchmark.ips do |x| + x.report("empty") do + User.query{{".to_s".id if building}} + end + + x.report("#group_by") do + User.group_by("foo"){{".to_s".id if building}} + end + + x.report("#having w/o args") do + User.having("foo"){{".to_s".id if building}} + end + + x.report("#having w/ single arg") do + User.having("foo", 42){{".to_s".id if building}} + end + + x.report("#having w/ two args") do + User.having("foo", 42, [43, 44]){{".to_s".id if building}} + end + + x.report("#insert w/ single attr. arg") do + User.insert(name: "John"){{".to_s".id if building}} + end + + x.report("#insert w/ two attr. args") do + User.insert(name: "John", active: true){{".to_s".id if building}} + end + + ref = User.new(uuid: UUID.random) + + x.report("#insert w/ two + ref. arg") do + User.insert(name: "John", referrer: ref){{".to_s".id if building}} + end + + x.report("#join w/ table") do + Post.join("users", "post.author_id = author.id", as: "author"){{".to_s".id if building}} + end + + x.report("#join w/ reference") do + Post.join(:author, select: {'*'}){{".to_s".id if building}} + end + + x.report("#limit") do + User.limit(1){{".to_s".id if building}} + end + + x.report("#offset") do + User.offset(1){{".to_s".id if building}} + end + + x.report("#order_by w/ string arg") do + User.order_by("foo", :desc){{".to_s".id if building}} + end + + x.report("#order_by w/ attr. arg") do + User.order_by(:uuid, :desc){{".to_s".id if building}} + end + + x.report("#returning w/ single string arg") do + User.returning("*"){{".to_s".id if building}} + end + + x.report("#returning w/ single char arg") do + User.returning('*'){{".to_s".id if building}} + end + + x.report("#returning w/ single attr. arg") do + User.returning(:uuid){{".to_s".id if building}} + end + + x.report("#returning w/ two args") do + User.returning(:uuid, "foo"){{".to_s".id if building}} + end + + x.report("#select w/ single char arg") do + User.select('*'){{".to_s".id if building}} + end + + x.report("#select w/ single string arg") do + User.select("*"){{".to_s".id if building}} + end + + x.report("#select w/ single attr. arg") do + User.select(:uuid){{".to_s".id if building}} + end + + x.report("#select w/ two args") do + User.select(:uuid, "foo"){{".to_s".id if building}} + end + + x.report("#set w/ single attr. arg") do + User.set(name: "John"){{".to_s".id if building}} + end + + x.report("#set w/ two attr. args") do + User.set(name: "John", active: true){{".to_s".id if building}} + end + + x.report("#set w/ single ref. arg") do + User.set(referrer: ref){{".to_s".id if building}} + end + + x.report("#where w/ single attr. arg") do + User.where(name: "John"){{".to_s".id if building}} + end + + x.report("#where w/ two attr. args") do + User.where(name: "John", active: true){{".to_s".id if building}} + end + + x.report("#where w/ single ref. arg") do + User.where(referrer: ref){{".to_s".id if building}} + end + end + {% end %} +end + +puts "\nCompleted in #{TimeFormat.auto(elapsed)} ✔️".colorize(COLORS["success"]) diff --git a/db_spec/pg/migration.sql b/db_spec/pg/migration.sql new file mode 100644 index 0000000..85583bd --- /dev/null +++ b/db_spec/pg/migration.sql @@ -0,0 +1,36 @@ +DROP TABLE IF EXISTS posts; +DROP TABLE IF EXISTS tags; +DROP TABLE IF EXISTS users; +DROP TYPE IF EXISTS users_role; +DROP TYPE IF EXISTS users_permissions; + +CREATE TYPE users_role AS ENUM ('writer', 'moderator', 'admin'); +CREATE TYPE users_permissions AS ENUM ('create_posts', 'edit_posts'); + +CREATE TABLE users( + uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + referrer_uuid UUID REFERENCES users (uuid) ON DELETE SET NULL, + activity_status BOOL NOT NULL DEFAULT true, + role users_role NOT NULL DEFAULT 'writer', + permissions users_permissions[] NOT NULL DEFAULT '{create_posts}', + name VARCHAR(100) NOT NULL, + balance REAL NOT NULL DEFAULT 0, + meta JSON NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ +); + +CREATE TABLE tags( + id SERIAL PRIMARY KEY, + content TEXT NOT NULL +); + +CREATE TABLE posts( + id SERIAL PRIMARY KEY, + author_uuid UUID NOT NULL REFERENCES users (uuid), + editor_uuid UUID REFERENCES users (uuid), + tag_ids INT[], + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ +); diff --git a/db_spec/pg/pg_spec.cr b/db_spec/pg/pg_spec.cr new file mode 100644 index 0000000..2411615 --- /dev/null +++ b/db_spec/pg/pg_spec.cr @@ -0,0 +1 @@ +require "pg" diff --git a/db_spec/pg/repository/exec_spec.cr b/db_spec/pg/repository/exec_spec.cr new file mode 100644 index 0000000..3acc078 --- /dev/null +++ b/db_spec/pg/repository/exec_spec.cr @@ -0,0 +1,37 @@ +require "../pg_spec" +require "../../repository_spec" + +describe "Repository(Postgres)#exec" do + repo = repo(:postgresql) + describe "with SQL" do + context "without params" do + it do + repo.exec("SELECT 1").should be_truthy + end + end + + context "with single param" do + it do + repo.exec("SELECT ?::int", 1).should be_truthy + end + end + + context "with multiple params" do + it do + repo.exec("SELECT ?::int, ?::text", 1, "foo").should be_truthy + end + end + + context "with single array of params" do + it do + repo.exec("SELECT ?::int[]", {[1, 2]}).should be_truthy + end + end + + context "with multiple arguments which have an array of params" do + it do + repo.exec("SELECT ?::text, ?::int[]", "foo", [1, 2]).should be_truthy + end + end + end +end diff --git a/db_spec/pg/repository/query_spec.cr b/db_spec/pg/repository/query_spec.cr new file mode 100644 index 0000000..28fd28e --- /dev/null +++ b/db_spec/pg/repository/query_spec.cr @@ -0,0 +1,132 @@ +require "../pg_spec" +require "../../repository_spec" + +describe "Repository(Postgres)#query" do + repo = repo(:postgresql) + + context "with Query" do + user = uninitialized User + + describe "insert" do + context "with a simple model" do + user = repo.query(User.insert( + name: "John", + active: (rand > 0.5 ? DB::Default : true), + balance: DB::Default, + )).first + + it "returns instance" do + user.should be_a(User) + end + end + end + + referrer = repo.query(User.insert(name: "Jake")).first + + describe "update" do + context "with attributes" do + user = repo.query(User.update.set(active: (rand > 0.5 ? DB::Default : false)).set(balance: 100.0_f32).where(uuid: user.uuid.not_nil!).returning(:uuid, :balance)).first + + it "preloads attributes" do + user.uuid.should be_a(UUID) + user.balance.should eq 100.0 + end + end + + context "with direct references" do + user = repo.query(User.update.set(referrer: referrer).where(uuid: user.uuid.not_nil!)).first + + it "preloads references" do + user.referrer.not_nil!.uuid.should be_a(UUID) + end + end + end + + describe "where" do + user = repo.query(User.where(uuid: user.uuid.not_nil!).and_where(balance: 100.0_f32)).first + + it "returns instance" do + user.should be_a(User) + end + + context "with direct non-enumerable join" do + user = repo.query(User + .where(uuid: user.uuid.not_nil!) + .join(:referrer, select: '*') + .select(:name, :uuid) + ).first + + it "returns a User instance" do + user.name.should eq "John" + end + + it "preloads direct references" do + user.referrer.not_nil!.name.should eq "Jake" + end + end + end + + tag = repo.query(Tag.insert(content: "foo")).first + post = uninitialized Post + + describe "insert" do + context "with complex model" do + post = repo.query(Post.insert(author: user, tags: [tag], content: "Blah-blah")).first + + it "returns model instance" do + post.should be_a(Post) + end + + it "preloads direct non-enumerable references" do + post.author.not_nil!.uuid.should eq user.uuid + post.author.not_nil!.name.should be_nil + end + + it "preloads direct enumerable references" do + post.tags.not_nil!.size.should eq 1 + post.tags.not_nil!.first.id.should eq tag.id + post.tags.not_nil!.first.content.should be_nil + end + end + end + + new_user = repo.query(User.insert(name: "James")).first + + describe "update" do + context "with complex reference updates" do + post = repo.query(Post.update.set(tags: [] of Tag).set(editor: new_user).set(created_at: DB::Default).where(id: post.id.not_nil!)).first + + it "returns model instance" do + post.should be_a(Post) + end + + it "preloads direct non-enumerable references" do + post.editor.not_nil!.uuid.should eq new_user.uuid + end + + it "preloads direct enumerable references" do + post.tags.not_nil!.size.should eq 0 + end + end + end + + describe "where" do + context "with foreign non-enumerable join" do + post = repo.query(Post + .where(id: post.id.not_nil!).and("cardinality(tag_ids) = ?", 0) + .join(:author, select: '*') + .join(:editor, select: {"editor." + User.uuid}) + ).first + + it "returns model instance" do + post.should be_a(Post) + end + + it "preloads references" do + post.author.not_nil!.uuid.should eq user.uuid + post.editor.not_nil!.uuid.should eq new_user.uuid + end + end + end + end +end diff --git a/db_spec/pg/repository/scalar_spec.cr b/db_spec/pg/repository/scalar_spec.cr new file mode 100644 index 0000000..7ff0e17 --- /dev/null +++ b/db_spec/pg/repository/scalar_spec.cr @@ -0,0 +1,26 @@ +require "../pg_spec" +require "../../repository_spec" + +describe "Repository(Postgres)#scalar" do + repo = repo(:postgresql) + + describe "with SQL" do + context "without params" do + it do + repo.scalar("SELECT 1").as(Int32).should eq 1 + end + end + + context "with single param" do + it do + repo.scalar("SELECT ?::int", 1).as(Int32).should eq 1 + end + end + + context "with array of params" do + it do + repo.scalar("SELECT ?::int[]", {[1, 2]}).as(Array(PG::Int32Array)).should eq [1, 2] + end + end + end +end diff --git a/db_spec/repository_spec.cr b/db_spec/repository_spec.cr new file mode 100644 index 0000000..47bb567 --- /dev/null +++ b/db_spec/repository_spec.cr @@ -0,0 +1,9 @@ +require "./spec_helper" + +enum Database + Postgresql +end + +def repo(database : Database) + Core::Repository.new(DB.open(ENV["#{database.to_s.upcase}_URL"]), Core::Logger::IO.new(STDOUT)) +end diff --git a/db_spec/spec_helper.cr b/db_spec/spec_helper.cr new file mode 100644 index 0000000..2a917c9 --- /dev/null +++ b/db_spec/spec_helper.cr @@ -0,0 +1,2 @@ +require "spec" +require "../spec/models" diff --git a/shard.yml b/shard.yml index 26edef3..336b1a3 100644 --- a/shard.yml +++ b/shard.yml @@ -1,20 +1,20 @@ name: core -version: 0.4.2 +version: 0.5.0 authors: - Vlad Faust -crystal: 0.26.0 +crystal: master license: MIT dependencies: - db: - github: crystal-lang/crystal-db - version: ~> 0.5.0 time_format: github: vladfaust/time_format.cr version: ~> 0.1.0 + db: + github: crystal-lang/crystal-db + version: ~> 0.5.0 development_dependencies: pg: diff --git a/spec/converters/enum_array_spec.cr b/spec/converters/enum_array_spec.cr deleted file mode 100644 index 5844a80..0000000 --- a/spec/converters/enum_array_spec.cr +++ /dev/null @@ -1,26 +0,0 @@ -require "../../spec_helper" -require "../../../src/core/converters/enum_array" - -require "pg" - -db = DB.open(ENV["DATABASE_URL"] || raise "No DATABASE_URL is set!") - -enum EnumArraySpecEnum - Foo - Bar - Baz -end - -describe Core::Converters::EnumArray do - db.query_each("SELECT * FROM enum_arrays") do |rs| - it "returns array of enums from existing values" do - converted = Core::Converters::EnumArray(EnumArraySpecEnum, Int16).from_rs(rs) - converted.should eq [EnumArraySpecEnum::Bar, EnumArraySpecEnum::Baz] - end - - it "returns Nil for NULL value" do - converted = Core::Converters::EnumArray(EnumArraySpecEnum, Int32).from_rs(rs) - converted.should be_nil - end - end -end diff --git a/spec/converters/enum_spec.cr b/spec/converters/enum_spec.cr deleted file mode 100644 index f302e13..0000000 --- a/spec/converters/enum_spec.cr +++ /dev/null @@ -1,25 +0,0 @@ -require "../spec_helper" -require "../../src/core/converters/enum" - -require "pg" - -db = DB.open(ENV["DATABASE_URL"] || raise "No DATABASE_URL is set!") - -enum EnumSpecEnum - Foo - Bar -end - -describe Core::Converters::Enum do - db.query_one("SELECT * FROM enums") do |rs| - it "returns Enum for existing value" do - converted = Core::Converters::Enum(EnumSpecEnum).from_rs(rs) - converted.should eq EnumSpecEnum::Bar - end - - it "return Nil for NULL value" do - converted = Core::Converters::Enum(EnumSpecEnum).from_rs(rs) - converted.should be_nil - end - end -end diff --git a/spec/converters/pg/numeric_spec.cr b/spec/converters/pg/numeric_spec.cr deleted file mode 100644 index ebd39c0..0000000 --- a/spec/converters/pg/numeric_spec.cr +++ /dev/null @@ -1,20 +0,0 @@ -require "../../spec_helper" -require "../../../src/core/converters/pg/numeric" - -require "pg" - -db = DB.open(ENV["DATABASE_URL"] || raise "No DATABASE_URL is set!") - -describe Core::Converters::PG::Numeric do - db.query_one("SELECT * FROM pg_numeric") do |rs| - it "returns Float64 for PG::Numeric value" do - converted = Core::Converters::PG::Numeric.from_rs(rs) - converted.should be_a Float64 - end - - it "return Nil for NULL value" do - converted = Core::Converters::PG::Numeric.from_rs(rs) - converted.should be_nil - end - end -end diff --git a/spec/logger/dummy_spec.cr b/spec/logger/dummy_spec.cr new file mode 100644 index 0000000..01a7988 --- /dev/null +++ b/spec/logger/dummy_spec.cr @@ -0,0 +1,9 @@ +require "../spec_helper" +require "../../src/core/logger/dummy" + +describe Core::Logger::Dummy do + it do + logger = Core::Logger::Dummy.new + logger.wrap("foo") { "bar" }.should eq "bar" + end +end diff --git a/spec/logger/io_spec.cr b/spec/logger/io_spec.cr new file mode 100644 index 0000000..c841b47 --- /dev/null +++ b/spec/logger/io_spec.cr @@ -0,0 +1,11 @@ +require "../spec_helper" +require "../../src/core/logger/io" + +describe Core::Logger::IO do + it do + io = IO::Memory.new + logger = Core::Logger::IO.new(io, false) + logger.wrap("foo") { "bar" }.should eq "bar" + io.to_s.should match %r{foo\n.+s\n} + end +end diff --git a/spec/logger/standard_spec.cr b/spec/logger/standard_spec.cr new file mode 100644 index 0000000..551c794 --- /dev/null +++ b/spec/logger/standard_spec.cr @@ -0,0 +1,12 @@ +require "../spec_helper" +require "../../src/core/logger/standard" + +describe Core::Logger::Standard do + it do + io = IO::Memory.new + standard_logger = ::Logger.new(io, ::Logger::Severity::DEBUG) + logger = Core::Logger::Standard.new(standard_logger, ::Logger::Severity::INFO, false) + logger.wrap("foo") { "bar" }.should eq "bar" + io.to_s.should match %r{I, \[.+\] INFO -- : foo\nI, \[.+\] INFO -- : .+s\n} + end +end diff --git a/spec/migration.sql b/spec/migration.sql deleted file mode 100644 index 43b3ffe..0000000 --- a/spec/migration.sql +++ /dev/null @@ -1,49 +0,0 @@ --- Run this before tests: - -DROP TABLE IF EXISTS posts; -DROP TABLE IF EXISTS users; -DROP TABLE IF EXISTS pg_numeric; -DROP TABLE IF EXISTS enums; -DROP TABLE IF EXISTS enum_arrays; - -CREATE TABLE users( - id SERIAL PRIMARY KEY, - referrer_id INT REFERENCES users (id), - active BOOL NOT NULL DEFAULT true, - role INT NOT NULL DEFAULT 0, - name VARCHAR(100) NOT NULL, - permissions SMALLINT[] NOT NULL DEFAULT '{0}', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ -); - -CREATE TABLE posts( - id SERIAL PRIMARY KEY, - author_id INT NOT NULL REFERENCES users (id), - editor_id INT REFERENCES users (id), - content TEXT NOT NULL, - tags TEXT[], - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ -); - -CREATE TABLE pg_numeric( - foo NUMERIC(16, 8) NOT NULL, - bar NUMERIC(16, 8) -); - -INSERT INTO pg_numeric (foo, bar) VALUES (12345678.00000001, NULL); - -CREATE TABLE enums( - foo SMALLINT NOT NULL, - bar SMALLINT -); - -INSERT INTO enums (foo, bar) VALUES (1, NULL); - -CREATE TABLE enum_arrays( - foo SMALLINT[] NOT NULL, - bar INT[] -); - -INSERT INTO enum_arrays (foo, bar) VALUES ('{1,2}', NULL); diff --git a/spec/models.cr b/spec/models.cr new file mode 100644 index 0000000..5aceda8 --- /dev/null +++ b/spec/models.cr @@ -0,0 +1,68 @@ +require "../src/core" + +class User + include Core::Schema + + enum Role + Writer + Moderator + Admin + end + + enum Permission + CreatePosts + EditPosts + end + + struct Meta + include JSON::Serializable + property foo : String? + + def initialize(@foo = nil) + end + end + + schema users do + pkey uuid : UUID + type referrer : Union(User | Nil), key: "referrer_uuid" + + type active : Bool = DB::Default, key: "activity_status" + type role : Role = DB::Default + type permissions : Array(Permission) = DB::Default + type name : String + type balance : Float32 = DB::Default + type meta : Meta = DB::Default + + type created_at : Time = DB::Default + type updated_at : Union(Time | Nil) + + type referrals : Array(User), foreign_key: "referrer_uuid" + type authored_posts : Array(Post), foreign_key: "author_uuid" + type edited_posts : Array(Post), foreign_key: "editor_uuid" + end +end + +class Tag + include Core::Schema + + schema tags do + pkey id : Int32 + type content : String + type posts : Array(Post), foreign_key: "tag_ids" + end +end + +class Post + include Core::Schema + + schema posts do + pkey id : Int32 + type author : User, key: "author_uuid" + type editor : Union(User | Nil), key: "editor_uuid" + type tags : Array(Tag) = DB::Default, key: "tag_ids" + + type content : String + type created_at : Time = DB::Default + type updated_at : Union(Time | Nil) + end +end diff --git a/spec/params_spec.cr b/spec/params_spec.cr deleted file mode 100644 index 2365431..0000000 --- a/spec/params_spec.cr +++ /dev/null @@ -1,31 +0,0 @@ -require "./spec_helper" -require "../src/core/params" - -module ParamsSpec - describe "Core.prepare_params" do - context "with single argument" do - it do - Core.prepare_params(42).should eq ({42}) - end - end - - context "with multiple arguments" do - it do - Core.prepare_params(42, "foo").should eq ({42, "foo"}) - end - end - - context "with single enumerable argument" do - it do - Core.prepare_params([42, 43]).should eq ({42, 43}) - end - end - - # TODO: Flatten? - context "with multiple enumerable arguments" do - it do - Core.prepare_params([42, 43], "foo").should eq ({[42, 43], "foo"}) - end - end - end -end diff --git a/spec/query/delete_spec.cr b/spec/query/delete_spec.cr new file mode 100644 index 0000000..9fe3e03 --- /dev/null +++ b/spec/query/delete_spec.cr @@ -0,0 +1,15 @@ +require "../models" + +describe "Query#delete" do + it do + uuid = UUID.random + + q = Core::Query(User).new.delete.where(uuid: uuid) + + q.to_s.should eq <<-SQL + DELETE FROM users WHERE (users.uuid = ?) + SQL + + q.params.should eq [uuid.to_s] + end +end diff --git a/spec/query/group_by_spec.cr b/spec/query/group_by_spec.cr index f9eed1c..8201303 100644 --- a/spec/query/group_by_spec.cr +++ b/spec/query/group_by_spec.cr @@ -1,19 +1,10 @@ -require "../spec_helper" +require "../models" -require "../../src/core/schema" -require "../../src/core/query" - -module QueryGroupBySpec - class User - include Core::Schema - schema :users { } - end - - describe "#group_by" do - it do - Core::Query.new(User).group_by("foo.id", "bar.id").to_s.should eq <<-SQL - SELECT users.* FROM users GROUP BY foo.id, bar.id - SQL - end +describe "Query#group_by" do + it do + q = Core::Query(User).new.group_by("foo", "bar") + q.to_s.should eq <<-SQL + SELECT users.* FROM users GROUP BY foo, bar + SQL end end diff --git a/spec/query/having_spec.cr b/spec/query/having_spec.cr index 3da434e..77532fb 100644 --- a/spec/query/having_spec.cr +++ b/spec/query/having_spec.cr @@ -1,218 +1,125 @@ -require "../spec_helper" +require "../models" -require "../../src/core/schema" -require "../../src/core/query" -require "../../src/core/converters/enum" - -module QueryHavingSpec - class User - include Core::Schema - include Core::Query - - enum Role - User - Admin - end - - schema :users do - primary_key :id - field :role, Role, converter: Core::Converters::Enum(Role) - field :name, String - end - end +describe "Query#having" do + context "without params" do + it do + q = Core::Query(User).new.having("foo") - class Post - include Core::Schema - include Core::Query + q.to_s.should eq <<-SQL + SELECT users.* FROM users HAVING (foo) + SQL - schema :posts do - reference :author, User, key: :author_id + q.params.should eq nil end end - describe "complex having" do + context "with params" do it do - query = User.having(id: 42).and("char_length(name) > ?", [3]).or(role: User::Role::Admin, name: !nil) + q = Core::Query(User).new.having("foo = ? AND bar = ?", 42, [43, 44]).having("foo") - query.to_s.should eq <<-SQL - SELECT users.* FROM users HAVING (users.id = ?) AND (char_length(name) > ?) OR (users.role = ? AND users.name IS NOT NULL) + q.to_s.should eq <<-SQL + SELECT users.* FROM users HAVING (foo = ? AND bar = ?) AND (foo) SQL - query.params.should eq([42, 3, 1]) + q.params.should eq [42, [43, 44]] end end - describe "having" do - context "with named arguments" do - context "with one clause" do - it do - query = User.having(id: 42) - - query.to_s.should eq <<-SQL - SELECT users.* FROM users HAVING (users.id = ?) - SQL - - query.params.should eq([42]) - end - end - - context "with multiple clauses" do - it do - query = User.having(id: 42, name: nil) - - query.to_s.should eq <<-SQL - SELECT users.* FROM users HAVING (users.id = ? AND users.name IS NULL) - SQL - - query.params.should eq([42]) - end - end - - context "with multiple calls" do - it do - query = User.having(id: 42, name: nil).having(role: User::Role::Admin) - - query.to_s.should eq <<-SQL - SELECT users.* FROM users HAVING (users.id = ? AND users.name IS NULL) AND (users.role = ?) - SQL - - query.params.should eq([42, 1]) - end - end - - context "with reference" do - user = User.new(id: 42) - - it do - query = Post.having(author: user) - - query.to_s.should eq <<-SQL - SELECT posts.* FROM posts HAVING (posts.author_id = ?) - SQL - - query.params.should eq([42]) - end - - expect_raises ArgumentError do - query = Post.having(writer: user) - query.to_s - end - end - - context "with nil reference" do - it do - query = Post.having(author: nil) - - query.to_s.should eq <<-SQL - SELECT posts.* FROM posts HAVING (posts.author_id IS NULL) - SQL - end - end - - context "with reference key" do - user = User.new(id: 42) - + describe "shorthands" do + describe "#having_not" do + context "without params" do it do - query = Post.having(author_id: user.id) - - query.to_s.should eq <<-SQL - SELECT posts.* FROM posts HAVING (posts.author_id = ?) + Core::Query(User).new.having_not("foo = 'bar'").to_s.should eq <<-SQL + SELECT users.* FROM users HAVING NOT (foo = 'bar') SQL - - query.params.should eq([42]) - end - - expect_raises ArgumentError do - query = Post.having(writer_id: user.id) - query.to_s end end - end - context "with string argument" do context "with params" do it do - query = User.having("char_length(name) > ?", [3]) + q = Core::Query(User).new.having_not("foo = ?", 42) - query.to_s.should eq <<-SQL - SELECT users.* FROM users HAVING (char_length(name) > ?) + q.to_s.should eq <<-SQL + SELECT users.* FROM users HAVING NOT (foo = ?) SQL - query.params.should eq([3]) - end - end - - context "without params" do - it do - query = User.having("name IS NOT NULL") - - query.to_s.should eq <<-SQL - SELECT users.* FROM users HAVING (name IS NOT NULL) - SQL - - query.params.empty?.should be_truthy + q.params.should eq [42] end end end - end - describe "#not_having" do - it do - query = User.not_having(id: 42, name: nil) + describe "manually tested" do + uuid = UUID.random - query.to_s.should eq <<-SQL - SELECT users.* FROM users HAVING NOT (users.id = ? AND users.name IS NULL) - SQL + it do + q = Core::Query(User).new.having("activity_status IS NOT NULL").and_not("name = ?", "John").or_having("foo") - query.params.should eq([42]) - end - end - - describe "#or_having" do - it do - query = User.having(id: 42, name: nil).or_having(role: User::Role::Admin) - - query.to_s.should eq <<-SQL - SELECT users.* FROM users HAVING (users.id = ? AND users.name IS NULL) OR (users.role = ?) - SQL + q.to_s.should eq <<-SQL + SELECT users.* FROM users HAVING (activity_status IS NOT NULL) AND NOT (name = ?) OR (foo) + SQL - query.params.should eq([42, 1]) - end - end - - describe "#or_not_having" do - it do - query = User.having(id: 42, name: nil).or_not_having(role: User::Role::Admin) - - query.to_s.should eq <<-SQL - SELECT users.* FROM users HAVING (users.id = ? AND users.name IS NULL) OR NOT (users.role = ?) - SQL - - query.params.should eq([42, 1]) - end - end - - describe "#and_having" do - it do - query = User.having(id: 42, name: nil).and_having(role: User::Role::Admin) - - query.to_s.should eq <<-SQL - SELECT users.* FROM users HAVING (users.id = ? AND users.name IS NULL) AND (users.role = ?) - SQL - - query.params.should eq([42, 1]) + q.params.should eq ["John"] + end end - end - describe "#and_not_having" do - it do - query = User.having(id: 42, name: nil).and_not_having(role: User::Role::Admin) - - query.to_s.should eq <<-SQL - SELECT users.* FROM users HAVING (users.id = ? AND users.name IS NULL) AND NOT (users.role = ?) - SQL - - query.params.should eq([42, 1]) - end + # It has almost zero benefit for you as a reader, but it allows to check that all methods delegate their arguments as expected. + # + # Methods which are tested: + # + # - `#or_having_not` + # - `#or_having` + # - `#and_having_not` + # - `#and_having` + # + # Each method has two variants (clause with params, single clause) and two situations - when it's called for first time (e.g. `Query.new.and_having`) and when it's called afterwards (e.g. `Query.new.having.and_having`), which results in 16 tests. I decided that it would be simpler to use macros, which however require some skill to understand. + {% for or in [true, false] %} + {% for not in [true, false] %} + describe '#' + {{(or ? "or" : "and")}} + "_having" do + context "when first call" do + context "without params" do + it do + Core::Query(User).new.{{(or ? "or" : "and").id}}_having{{"_not".id if not}}("foo = 'bar'").to_s.should eq <<-SQL + SELECT users.* FROM users HAVING {{"NOT ".id if not}}(foo = 'bar') + SQL + end + end + + context "with params" do + it do + q = Core::Query(User).new.{{(or ? "or" : "and").id}}_having{{"_not".id if not}}("foo = ?", 42) + + q.to_s.should eq <<-SQL + SELECT users.* FROM users HAVING {{"NOT ".id if not}}(foo = ?) + SQL + + q.params.should eq [42] + end + end + end + + context "when non-first call" do + context "without params" do + it do + Core::Query(User).new.having("first = true").{{(or ? "or" : "and").id}}_having{{"_not".id if not}}("foo = 'bar'").to_s.should eq <<-SQL + SELECT users.* FROM users HAVING (first = true) {{or ? "OR ".id : "AND ".id}}{{"NOT ".id if not}}(foo = 'bar') + SQL + end + end + + context "with params" do + it do + q = Core::Query(User).new.having("first = true").{{(or ? "or" : "and").id}}_having{{"_not".id if not}}("foo = ?", 42) + + q.to_s.should eq <<-SQL + SELECT users.* FROM users HAVING (first = true) {{or ? "OR ".id : "AND ".id}}{{"NOT ".id if not}}(foo = ?) + SQL + + q.params.should eq [42] + end + end + end + end + {% end %} + {% end %} end end diff --git a/spec/query/insert_spec.cr b/spec/query/insert_spec.cr new file mode 100644 index 0000000..2c7a0f5 --- /dev/null +++ b/spec/query/insert_spec.cr @@ -0,0 +1,29 @@ +require "../models" + +describe "Query#insert" do + context "with minimum arguments" do + it do + q = Core::Query(User).new.insert(name: "John") + + q.to_s.should eq <<-SQL + INSERT INTO users (name) VALUES (?) + SQL + + q.params.should eq ["John"] + end + end + + context "with many arguments" do + it do + ref_uuid = UUID.random + + q = Core::Query(User).new.insert(referrer: User.new(uuid: ref_uuid), active: DB::Default, role: User::Role::Moderator, permissions: [User::Permission::EditPosts], name: "John") + + q.to_s.should eq <<-SQL + INSERT INTO users (referrer_uuid, role, permissions, name) VALUES (?, ?, ?, ?) + SQL + + q.params.should eq [ref_uuid.to_s, "moderator", ["edit_posts"], "John"] + end + end +end diff --git a/spec/query/join_spec.cr b/spec/query/join_spec.cr index 9336a7c..438d377 100644 --- a/spec/query/join_spec.cr +++ b/spec/query/join_spec.cr @@ -1,131 +1,53 @@ -require "../spec_helper" +require "../models" -require "../../src/core/schema" -require "../../src/core/query" - -module QueryJoinSpec - class User - include Core::Schema - include Core::Query - - schema :users do - primary_key :id - reference :referrer, User, key: :referrer_id - reference :referrals, Array(User), foreign_key: :referrer_id - reference :posts, Array(Post), foreign_key: :author_id - field :name, String - field :custom_field, String, key: :custom_key - end - end +describe "Query#join" do + context "explicit" do + it do + q = Core::Query(User).new.join("some_table", "users.some_keys @> some_table.key", type: :right) - class Post - include Core::Schema - include Core::Query + q.to_s.should eq <<-SQL + SELECT users.* FROM users RIGHT JOIN some_table ON users.some_keys @> some_table.key + SQL - schema :posts do - primary_key :id - reference :author, User, key: :author_id - reference :editor, User, key: :editor_id + q.params.should be_nil end end - describe "#join" do - context "with \"has_many\" reference" do + context "with foreign reference" do + context "without arguments" do it do - sql = <<-SQL - SELECT users.*, '' AS _posts, authored_posts.* FROM users JOIN posts AS "authored_posts" ON "authored_posts".author_id = users.id - SQL - - User.join(:posts, as: :authored_posts).to_s.should eq(sql.strip) - end - end + q = Core::Query(User).new.join(:authored_posts) - context "with \"belongs_to\" reference" do - it do - sql = <<-SQL - SELECT posts.*, '' AS _author, author.* FROM posts JOIN users AS "author" ON "author".id = posts.author_id + q.to_s.should eq <<-SQL + SELECT users.* FROM users INNER JOIN posts AS authored_posts ON authored_posts.author_uuid = users.uuid SQL - Post.join(:author).to_s.should eq(sql.strip) + q.params.should be_nil end end - context "with multiple calls" do + context "with arguments" do it do - sql = <<-SQL - SELECT posts.*, '' AS _author, authors.*, '' AS _editor, editor.* FROM posts JOIN users AS "authors" ON "authors".id = posts.author_id JOIN users AS "editor" ON "editor".id = posts.editor_id - SQL - - Post.join(:author, as: :authors).join(:editor).to_s.should eq(sql.strip) - end - end + q = Core::Query(User).new.join(:authored_posts, type: :right, as: "the_posts", select: {"the_posts.created_at", "the_posts.id"}) - context "with self references" do - context "\"has_many\"" do - it do - sql = <<-SQL - SELECT users.*, '' AS _referrals, referrals.* FROM users JOIN users AS "referrals" ON "referrals".referrer_id = users.id - SQL - - User.join(:referrals).to_s.should eq(sql.strip) - end - end - - context "\"belongs_to\"" do - it do - sql = <<-SQL - SELECT users.*, '' AS _referrer, referrer.* FROM users JOIN users AS "referrer" ON "referrer".id = users.referrer_id - SQL + q.to_s.should eq <<-SQL + SELECT '' AS _authored_posts, the_posts.created_at, the_posts.id FROM users RIGHT JOIN posts AS the_posts ON the_posts.author_uuid = users.uuid + SQL - User.join(:referrer).to_s.should eq(sql.strip) - end + q.params.should be_nil end end end - describe "#inner_join" do + context "with direct reference" do it do - User.inner_join(:posts).to_s.should contain("INNER JOIN") - end - end - - describe "select" do - it "works with nil select" do - sql = <<-SQL - SELECT users.* FROM users JOIN users AS "referrer" ON "referrer".id = users.referrer_id - SQL - - User.join(:referrer, select: nil).to_s.should eq(sql.strip) - end - - it "works with single select" do - sql = <<-SQL - SELECT users.*, '' AS _referrer, referrer.id FROM users JOIN users AS "referrer" ON "referrer".id = users.referrer_id - SQL - - User.join(:referrer, select: :id).to_s.should eq(sql.strip) - end + q = Core::Query(Post).new.join(:author, type: :left, select: "author.id") - it "works with multiple select" do - sql = <<-SQL - SELECT users.*, '' AS _referrer, ref.id, ref.custom_key FROM users JOIN users AS "ref" ON "ref".id = users.referrer_id + q.to_s.should eq <<-SQL + SELECT '' AS _author, author.id FROM posts LEFT JOIN users AS author ON posts.author_uuid = author.uuid SQL - User.join(:referrer, as: :ref, select: [:id, :custom_field]).to_s.should eq(sql.strip) + q.params.should be_nil end end - - {% for t in %i(left right full) %} - describe "#" + {{t.id.stringify}} + "_join" do - it do - User.{{t.id}}_join(:posts).to_s.should contain("{{t.upcase.id}} JOIN") - end - end - - describe "#" + {{t.id.stringify}} + "_outer_join" do - it do - User.{{t.id}}_outer_join(:posts).to_s.should contain("{{t.upcase.id}} OUTER JOIN") - end - end - {% end %} end diff --git a/spec/query/limit_spec.cr b/spec/query/limit_spec.cr index eed6e38..80b7d38 100644 --- a/spec/query/limit_spec.cr +++ b/spec/query/limit_spec.cr @@ -1,19 +1,27 @@ -require "../spec_helper" +require "../models" -require "../../src/core/schema" -require "../../src/core/query" +describe "Query#limit" do + context "with int argument" do + it do + q = Core::Query(User).new.limit(2) + + q.to_s.should eq <<-SQL + SELECT users.* FROM users LIMIT ? + SQL -module QueryLimitSpec - class User - include Core::Schema - schema :users { } + q.params.should eq [2] + end end - describe "#limit" do + context "with nil argument" do it do - Core::Query.new(User).limit(3).to_s.should eq <<-SQL - SELECT users.* FROM users LIMIT 3 + q = Core::Query(User).new.limit(nil) + + q.to_s.should eq <<-SQL + SELECT users.* FROM users SQL + + q.params.should be_nil end end end diff --git a/spec/query/offset_spec.cr b/spec/query/offset_spec.cr index 3d245a8..9272e54 100644 --- a/spec/query/offset_spec.cr +++ b/spec/query/offset_spec.cr @@ -1,19 +1,27 @@ -require "../spec_helper" +require "../models" -require "../../src/core/schema" -require "../../src/core/query" +describe "Query#offset" do + context "with int argument" do + it do + q = Core::Query(User).new.offset(2) + + q.to_s.should eq <<-SQL + SELECT users.* FROM users OFFSET ? + SQL -module QueryOffsetSpec - class User - include Core::Schema - schema :users { } + q.params.should eq [2] + end end - describe "#offset" do + context "with nil argument" do it do - Core::Query.new(User).offset(0).to_s.should eq <<-SQL - SELECT users.* FROM users OFFSET 0 + q = Core::Query(User).new.offset(nil) + + q.to_s.should eq <<-SQL + SELECT users.* FROM users SQL + + q.params.should be_nil end end end diff --git a/spec/query/order_by_spec.cr b/spec/query/order_by_spec.cr index db0ee6b..d17bde8 100644 --- a/spec/query/order_by_spec.cr +++ b/spec/query/order_by_spec.cr @@ -1,23 +1,27 @@ -require "../spec_helper" +require "../models" -require "../../src/core/schema" -require "../../src/core/query" +describe "Query#order_by" do + context "with attribute argument" do + it do + q = Core::Query(User).new.order_by(:active, :desc) -module QueryOrderBySpec - class User - include Core::Schema + q.to_s.should eq <<-SQL + SELECT users.* FROM users ORDER BY users.activity_status DESC + SQL - schema :users do - primary_key :id - field :name, String, key: :the_name_column + q.params.should be_nil end end - describe "#order_by" do + context "with string argument" do it do - Core::Query.new(User).order_by(:id, :desc).order_by(:name).order_by("custom_order").to_s.should eq <<-SQL - SELECT users.* FROM users ORDER BY id DESC, the_name_column, custom_order + q = Core::Query(User).new.order_by("some_column") + + q.to_s.should eq <<-SQL + SELECT users.* FROM users ORDER BY some_column ASC SQL + + q.params.should be_nil end end end diff --git a/spec/query/select_spec.cr b/spec/query/select_spec.cr index 8a5e6c3..751441a 100644 --- a/spec/query/select_spec.cr +++ b/spec/query/select_spec.cr @@ -1,43 +1,13 @@ -require "../spec_helper" +require "../models" -require "../../src/core/schema" -require "../../src/core/query" +describe "Query#select" do + it do + q = Core::Query(User).new.select(:active, "foo") -module QuerySelectSpec - class User - include Core::Schema - include Core::Query + q.to_s.should eq <<-SQL + SELECT users.activity_status, foo FROM users + SQL - schema :users do - primary_key :id - field :foo, String - field :bar, String, key: :baz - end - end - - describe "Query::Instance#select" do - context "with single argument" do - q = User.select("DISTINCT id") - - it do - q.to_s.should eq "SELECT DISTINCT id FROM users" - end - end - - context "with multiple arguments" do - q = User.select(:bar, "role", "*") - - it do - q.to_s.should eq "SELECT baz, role, * FROM users" - end - end - - context "when called multiple times" do - q = User.select(:id).select(:foo, :bar).select("DISTINCT role") - - it do - q.to_s.should eq "SELECT id, foo, baz, DISTINCT role FROM users" - end - end + q.params.should be_nil end end diff --git a/spec/query/set_spec.cr b/spec/query/set_spec.cr deleted file mode 100644 index bbf1f71..0000000 --- a/spec/query/set_spec.cr +++ /dev/null @@ -1,77 +0,0 @@ -require "../spec_helper" - -require "../../src/core/schema" -require "../../src/core/query" - -module QuerySetSpec - class User - include Core::Schema - schema :users do - primary_key :id - field :foo, String - field :bar, String, key: :baz - end - end - - describe "Query::Instance#set" do - context "with explicit clause" do - context "witout params" do - q = Core::Query.new(User).set("what") - - it "generates valid SQL" do - q.to_s.should eq("UPDATE users SET what") - end - end - - context "with params" do - q = Core::Query.new(User).set("what = ?", 42) - - it "generates valid SQL" do - q.to_s.should eq("UPDATE users SET what = ?") - end - - it "generates valid params" do - q.params.should eq [42] - end - end - end - - context "with hash arguments" do - context "with fields with same keys" do - q = Core::Query.new(User).set(id: 42, foo: "foo") - - it "generates valid SQL" do - q.to_s.should eq "UPDATE users SET id = ?, foo = ?" - end - - it "generates valid params" do - q.params.should eq [42, "foo"] - end - end - - context "with fields with differnt keys" do - q = Core::Query.new(User).set(id: 42, bar: "bar") - - it "generates valid SQL" do - q.to_s.should eq "UPDATE users SET id = ?, baz = ?" - end - - it "generates valid params" do - q.params.should eq [42, "bar"] - end - end - end - - context "when called multiple times" do - q = Core::Query.new(User).set("what = random() * ?", 5).set(id: 42).set(bar: "baz") - - it "generates valid SQL" do - q.to_s.should eq "UPDATE users SET what = random() * ?, id = ?, baz = ?" - end - - it "generates valid params" do - q.params.should eq [5, 42, "baz"] - end - end - end -end diff --git a/spec/query/update_spec.cr b/spec/query/update_spec.cr new file mode 100644 index 0000000..8da6e7d --- /dev/null +++ b/spec/query/update_spec.cr @@ -0,0 +1,16 @@ +require "../models" + +describe "Query#update" do + it do + uuid = UUID.random + ref_uuid = UUID.random + + q = Core::Query(User).new.update.set(name: "John", active: DB::Default).set(referrer: User.new(uuid: ref_uuid)).where(uuid: uuid) + + q.to_s.should eq <<-SQL + UPDATE users SET name = ?, activity_status = DEFAULT, referrer_uuid = ? WHERE (users.uuid = ?) + SQL + + q.params.should eq ["John", ref_uuid.to_s, uuid.to_s] + end +end diff --git a/spec/query/where_spec.cr b/spec/query/where_spec.cr index 520d9f0..c97ee6e 100644 --- a/spec/query/where_spec.cr +++ b/spec/query/where_spec.cr @@ -1,218 +1,195 @@ -require "../spec_helper" +require "../models" -require "../../src/core/schema" -require "../../src/core/query" -require "../../src/core/converters/enum" +describe "Query#where" do + context "with explicit clause" do + context "with params" do + it do + q = Core::Query(User).new.where("foo = ? AND bar = ?", 42, [43, 44]) -module QueryWhereSpec - class User - include Core::Schema - include Core::Query + q.to_s.should eq <<-SQL + SELECT users.* FROM users WHERE (foo = ? AND bar = ?) + SQL - enum Role - User - Admin + q.params.should eq [42, [43, 44]] + end end - schema :users do - primary_key :id - field :role, Role, converter: Core::Converters::Enum(Role) - field :name, String - end - end + context "without params" do + it do + q = Core::Query(User).new.where("foo") - class Post - include Core::Schema - include Core::Query + q.to_s.should eq <<-SQL + SELECT users.* FROM users WHERE (foo) + SQL - schema :posts do - reference :author, User, key: :author_id + q.params.should eq nil + end end end - describe "complex where" do + context "with attributes" do it do - query = User.where(id: 42).and("char_length(name) > ?", [3]).or(role: User::Role::Admin, name: !nil) + q = Core::Query(User).new.where(active: true, name: "John") - query.to_s.should eq <<-SQL - SELECT users.* FROM users WHERE (users.id = ?) AND (char_length(name) > ?) OR (users.role = ? AND users.name IS NOT NULL) + q.to_s.should eq <<-SQL + SELECT users.* FROM users WHERE (users.activity_status = ? AND users.name = ?) SQL - query.params.should eq([42, 3, 1]) + q.params.should eq [true, "John"] end end - describe "where" do - context "with named arguments" do - context "with one clause" do - it do - query = User.where(id: 42) - - query.to_s.should eq <<-SQL - SELECT users.* FROM users WHERE (users.id = ?) - SQL - - query.params.should eq([42]) - end - end - - context "with multiple clauses" do - it do - query = User.where(id: 42, name: nil) - - query.to_s.should eq <<-SQL - SELECT users.* FROM users WHERE (users.id = ? AND users.name IS NULL) - SQL - - query.params.should eq([42]) - end - end - - context "with multiple calls" do - it do - query = User.where(id: 42, name: nil).where(role: User::Role::Admin) - - query.to_s.should eq <<-SQL - SELECT users.* FROM users WHERE (users.id = ? AND users.name IS NULL) AND (users.role = ?) - SQL + context "with references" do + uuid = UUID.random - query.params.should eq([42, 1]) - end - end - - context "with reference" do - user = User.new(id: 42) + it do + q = Core::Query(Post).new.where(author: User.new(uuid: uuid)) - it do - query = Post.where(author: user) + q.to_s.should eq <<-SQL + SELECT posts.* FROM posts WHERE (posts.author_uuid = ?) + SQL - query.to_s.should eq <<-SQL - SELECT posts.* FROM posts WHERE (posts.author_id = ?) - SQL + q.params.should eq [uuid.to_s] + end + end - query.params.should eq([42]) + describe "shorthands" do + describe "#where_not" do + context "with explicit clause" do + context "without params" do + it do + Core::Query(User).new.where_not("foo = 'bar'").to_s.should eq <<-SQL + SELECT users.* FROM users WHERE NOT (foo = 'bar') + SQL + end end - expect_raises ArgumentError do - query = Post.where(writer: user) - query.to_s - end - end + context "with params" do + it do + q = Core::Query(User).new.where_not("foo = ?", 42) - context "with nil reference" do - it do - query = Post.where(author: nil) + q.to_s.should eq <<-SQL + SELECT users.* FROM users WHERE NOT (foo = ?) + SQL - query.to_s.should eq <<-SQL - SELECT posts.* FROM posts WHERE (posts.author_id IS NULL) - SQL + q.params.should eq [42] + end end end - context "with reference key" do - user = User.new(id: 42) - + context "with named arguments" do it do - query = Post.where(author_id: user.id) + q = Core::Query(User).new.where_not(active: true, name: "John") - query.to_s.should eq <<-SQL - SELECT posts.* FROM posts WHERE (posts.author_id = ?) + q.to_s.should eq <<-SQL + SELECT users.* FROM users WHERE NOT (users.activity_status = ? AND users.name = ?) SQL - query.params.should eq([42]) - end - - expect_raises ArgumentError do - query = Post.where(writer_id: user.id) - query.to_s + q.params.should eq [true, "John"] end end end - context "with string argument" do - context "with params" do - it do - query = User.where("char_length(name) > ?", [3]) + describe "manually tested" do + uuid = UUID.random - query.to_s.should eq <<-SQL - SELECT users.* FROM users WHERE (char_length(name) > ?) - SQL + it do + q = Core::Query(User).new.where(uuid: uuid).and_where("activity_status IS NOT NULL").and_not("name = ?", "John") - query.params.should eq([3]) - end - end + q.to_s.should eq <<-SQL + SELECT users.* FROM users WHERE (users.uuid = ?) AND (activity_status IS NOT NULL) AND NOT (name = ?) + SQL - context "without params" do - it do - query = User.where("name IS NOT NULL") - - query.to_s.should eq <<-SQL - SELECT users.* FROM users WHERE (name IS NOT NULL) - SQL - - query.params.empty?.should be_truthy - end + q.params.should eq [uuid.to_s, "John"] end end - end - - describe "#where_not" do - it do - query = User.where_not(id: 42, name: nil) - - query.to_s.should eq <<-SQL - SELECT users.* FROM users WHERE NOT (users.id = ? AND users.name IS NULL) - SQL - - query.params.should eq([42]) - end - end - - describe "#or_where" do - it do - query = User.where(id: 42, name: nil).or_where(role: User::Role::Admin) - - query.to_s.should eq <<-SQL - SELECT users.* FROM users WHERE (users.id = ? AND users.name IS NULL) OR (users.role = ?) - SQL - - query.params.should eq([42, 1]) - end - end - - describe "#or_where_not" do - it do - query = User.where(id: 42, name: nil).or_where_not(role: User::Role::Admin) - - query.to_s.should eq <<-SQL - SELECT users.* FROM users WHERE (users.id = ? AND users.name IS NULL) OR NOT (users.role = ?) - SQL - - query.params.should eq([42, 1]) - end - end - - describe "#and_where" do - it do - query = User.where(id: 42, name: nil).and_where(role: User::Role::Admin) - - query.to_s.should eq <<-SQL - SELECT users.* FROM users WHERE (users.id = ? AND users.name IS NULL) AND (users.role = ?) - SQL - - query.params.should eq([42, 1]) - end - end - - describe "#and_where_not" do - it do - query = User.where(id: 42, name: nil).and_where_not(role: User::Role::Admin) - - query.to_s.should eq <<-SQL - SELECT users.* FROM users WHERE (users.id = ? AND users.name IS NULL) AND NOT (users.role = ?) - SQL - query.params.should eq([42, 1]) - end + # It has almost zero benefit for you as a reader, but it allows to check that all methods delegate their arguments as expected. + # + # Methods which are tested: + # + # - `#or_where_not` + # - `#or_where` + # - `#and_where_not` + # - `#and_where` + # + # Each method has three variants (clause with params, single clause, named arguments) and two situations - when it's called for first time (e.g. `Query.new.and_where`) and when it's called afterwards (e.g. `Query.new.where.and_where`), which results in 24 tests. I decided that it would be simpler to use macros, which however require some skill to understand. + {% for or in [true, false] %} + {% for not in [true, false] %} + describe '#' + {{(or ? "or" : "and")}} + "_where" do + context "when first call" do + context "with explicit clause" do + context "without params" do + it do + Core::Query(User).new.{{(or ? "or" : "and").id}}_where{{"_not".id if not}}("foo = 'bar'").to_s.should eq <<-SQL + SELECT users.* FROM users WHERE {{"NOT ".id if not}}(foo = 'bar') + SQL + end + end + + context "with params" do + it do + q = Core::Query(User).new.{{(or ? "or" : "and").id}}_where{{"_not".id if not}}("foo = ?", 42) + + q.to_s.should eq <<-SQL + SELECT users.* FROM users WHERE {{"NOT ".id if not}}(foo = ?) + SQL + + q.params.should eq [42] + end + end + + context "with named arguments" do + it do + q = Core::Query(User).new.{{(or ? "or" : "and").id}}_where{{"_not".id if not}}(active: true, name: "John") + + q.to_s.should eq <<-SQL + SELECT users.* FROM users WHERE {{"NOT ".id if not}}(users.activity_status = ? AND users.name = ?) + SQL + + q.params.should eq [true, "John"] + end + end + end + end + + context "when non-first call" do + context "with explicit clause" do + context "without params" do + it do + Core::Query(User).new.where("first = true").{{(or ? "or" : "and").id}}_where{{"_not".id if not}}("foo = 'bar'").to_s.should eq <<-SQL + SELECT users.* FROM users WHERE (first = true) {{or ? "OR ".id : "AND ".id}}{{"NOT ".id if not}}(foo = 'bar') + SQL + end + end + + context "with params" do + it do + q = Core::Query(User).new.where("first = true").{{(or ? "or" : "and").id}}_where{{"_not".id if not}}("foo = ?", 42) + + q.to_s.should eq <<-SQL + SELECT users.* FROM users WHERE (first = true) {{or ? "OR ".id : "AND ".id}}{{"NOT ".id if not}}(foo = ?) + SQL + + q.params.should eq [42] + end + end + end + + context "with named arguments" do + it do + q = Core::Query(User).new.where("first = true").{{(or ? "or" : "and").id}}_where{{"_not".id if not}}(active: true, name: "John") + + q.to_s.should eq <<-SQL + SELECT users.* FROM users WHERE (first = true) {{or ? "OR ".id : "AND ".id}}{{"NOT ".id if not}}(users.activity_status = ? AND users.name = ?) + SQL + + q.params.should eq [true, "John"] + end + end + end + end + {% end %} + {% end %} end end diff --git a/spec/query_spec.cr b/spec/query_spec.cr index 2edfdf1..56b1123 100644 --- a/spec/query_spec.cr +++ b/spec/query_spec.cr @@ -1,202 +1,17 @@ -require "./spec_helper" - -require "../src/core/schema" -require "../src/core/query" -require "../src/core/converters/enum" - -alias Query = Core::Query - -module QuerySpec - class User - include Core::Schema - include Core::Query - - enum Role - User - Admin - end - - schema :users do - primary_key :id - field :name, String - field :role, Role, converter: Core::Converters::Enum - end - end - - describe Core::Query do - describe "#reset" do - it do - query = Query.new(User).order_by(:id).where("char_length(name) > ?", [1]).limit(3).offset(5).group_by("users.id", "posts.id").having("COUNT (posts.id) > ?", [1]) - - query.reset.to_s.should eq <<-SQL - SELECT users.* FROM users - SQL - end - end - - describe "#clone" do - query = User.select(:id).order_by(:id).where("char_length(name) > ?", [1]).limit(3).offset(5) - cloned_query = query.clone - - it "creates identical object" do - query.should eq cloned_query - end - - # It's a Struct, so #object_id is unnaceptable here (see https://crystal-lang.org/api/master/Reference.html#object_id%3AUInt64-instance-method, it says "The returned value is the memory address of this object.") - # #hash doesn't work too. - pending "has object with different identificator" do - end - - it "preserves clauses" do - cloned_query.to_s.should eq query.to_s - end - - it "creates object which references another inner objects" do - query.reset - cloned_query.to_s.should eq <<-SQL - SELECT id FROM users WHERE (char_length(name) > ?) ORDER BY id LIMIT 3 OFFSET 5 - SQL - cloned_query.params.should eq [1] - end - end - - describe "#update" do - it do - query = User.update.where(id: 3) - query.to_s.should eq <<-SQL - UPDATE users WHERE (users.id = ?) - SQL - end - end - - describe "#delete" do - it do - query = User.delete.where(id: 3) - query.to_s.should eq <<-SQL - DELETE FROM users WHERE (users.id = ?) - SQL - end - end - - describe "#all" do - it do - query = User.limit(3).offset(5) - query.all.to_s.should eq <<-SQL - SELECT users.* FROM users OFFSET 5 - SQL - end - end - - describe "#one" do - it do - User.one.to_s.should eq <<-SQL - SELECT users.* FROM users LIMIT 1 - SQL - end - end - - describe "#last" do - it do - User.last.to_s.should eq <<-SQL - SELECT users.* FROM users ORDER BY id DESC LIMIT 1 - SQL - end - end - - describe "#first" do - it do - User.first.to_s.should eq <<-SQL - SELECT users.* FROM users ORDER BY id ASC LIMIT 1 - SQL - end - end - - describe "#and" do - context "after \#where" do - it do - query = User.where(id: 42, name: !nil).and(role: User::Role::Admin) - - query.to_s.should eq <<-SQL - SELECT users.* FROM users WHERE (users.id = ? AND users.name IS NOT NULL) AND (users.role = ?) - SQL - - query.params.should eq([42, 1]) - end - end - - context "after #or_where" do - it do - query = User.where(id: 43).or_where(id: 42, name: nil).and(role: User::Role::Admin) - - query.to_s.should eq <<-SQL - SELECT users.* FROM users WHERE (users.id = ?) OR (users.id = ? AND users.name IS NULL) AND (users.role = ?) - SQL - - query.params.should eq([43, 42, 1]) - end - end - end - - describe "#and_not" do - it do - query = User.where(id: 42, name: !nil).and_not(role: User::Role::Admin) - - query.to_s.should eq <<-SQL - SELECT users.* FROM users WHERE (users.id = ? AND users.name IS NOT NULL) AND NOT (users.role = ?) - SQL - - query.params.should eq([42, 1]) - end - end - - describe "#or" do - context "after \#where" do - it do - query = User.where(id: 42).or(role: User::Role::Admin, name: nil) - - query.to_s.should eq <<-SQL - SELECT users.* FROM users WHERE (users.id = ?) OR (users.role = ? AND users.name IS NULL) - SQL - - query.params.should eq([42, 1]) - end - end - - context "after #or_where" do - it do - query = User.or_where(id: 42, name: !nil).or(role: User::Role::Admin) - - query.to_s.should eq <<-SQL - SELECT users.* FROM users WHERE (users.id = ? AND users.name IS NOT NULL) OR (users.role = ?) - SQL - - query.params.should eq([42, 1]) - end - end - end - - describe "#or_not" do - it do - query = User.having(id: 42).or_not(role: User::Role::Admin, name: nil) - - query.to_s.should eq <<-SQL - SELECT users.* FROM users HAVING (users.id = ?) OR NOT (users.role = ? AND users.name IS NULL) - SQL - - query.params.should eq([42, 1]) - end - end - - describe "complex #and & #or" do - it do - query = User.where(id: [42, 43, 44]).having("char_length(name) > ?", [3]).and(role: User::Role::Admin).and_where(name: nil).or("id > ?", [24]).and_not(name: "john") - - query.to_s.should eq <<-SQL - SELECT users.* FROM users WHERE (users.id IN (?, ?, ?)) AND (users.name IS NULL) OR (id > ?) AND NOT (users.name = ?) HAVING (char_length(name) > ?) AND (users.role = ?) - SQL - - query.params.should eq([42, 43, 44, 24, "john", 3, 1]) - end - end - end +require "./models" + +describe "Query" do + {% for joinder in %w(and or) %} + {% for not in [true, false] %} + describe '#' + {{joinder}} + {{not ? "_not" : ""}} do + {% for wherish in %w(where having) %} + context "after " + {{wherish}} do + it "calls " + {{wherish}} do + Core::Query(User).new.{{wherish.id}}("foo").{{joinder.id}}{{"_not".id if not}}("bar").should eq Core::Query(User).new.{{wherish.id}}("foo").{{joinder.id}}_{{wherish.id}}{{"_not".id if not}}("bar") + end + end + {% end %} + end + {% end %} + {% end %} end diff --git a/spec/repository/exec_spec.cr b/spec/repository/exec_spec.cr new file mode 100644 index 0000000..aa37784 --- /dev/null +++ b/spec/repository/exec_spec.cr @@ -0,0 +1,36 @@ +require "./mock_db" + +describe Core::Repository do + db = MockDB.new + repo = Core::Repository.new(db) + + describe "#exec" do + context "with paramsless Query" do + result = repo.exec(Core::Query(User).new.update.set("foo = 42")) + + it "calls DB#exec with valid sql" do + db.latest_exec_sql.should eq <<-SQL + UPDATE users SET foo = 42 + SQL + end + + it "does not pass any params to DB#exec" do + db.latest_exec_params.should be_nil + end + end + + context "with params Query" do + result = repo.exec(Core::Query(User).new.update.set(active: true)) + + it "calls DB#exec with valid sql" do + db.latest_exec_sql.should eq <<-SQL + UPDATE users SET activity_status = ? + SQL + end + + it "pass params to DB#exec" do + db.latest_exec_params.should eq [true] + end + end + end +end diff --git a/spec/repository/mock_db.cr b/spec/repository/mock_db.cr new file mode 100644 index 0000000..b7c119f --- /dev/null +++ b/spec/repository/mock_db.cr @@ -0,0 +1,68 @@ +class MockDB + record ResultSet + + class Driver + end + + getter driver = Driver.new + + def initialize + end + + getter latest_exec_sql : String? = nil + getter latest_exec_params : Array(DB::Any | Array(DB::Any))? = nil + + def exec(sql, *params : DB::Any | Array(DB::Any)) + @latest_exec_sql = sql + @latest_exec_params = params.to_a + return DB::ExecResult.new(0, 0) + end + + def exec(sql, params : Enumerable(DB::Any | Array(DB::Any))? = nil) + @latest_exec_sql = sql + @latest_exec_params = params.try &.to_a + return DB::ExecResult.new(0, 0) + end + + getter latest_scalar_sql : String? = nil + getter latest_scalar_params : Array(DB::Any | Array(DB::Any))? = nil + + def scalar(sql : String, *params) + @latest_scalar_sql = sql + @latest_scalar_params = params.to_a + end + + def scalar(sql : String, params : Enumerable(DB::Any | Array(DB::Any))? = nil) + @latest_scalar_sql = sql + @latest_scalar_params = params.try &.to_a + nil.as(DB::Any) + end + + getter latest_query_sql : String? = nil + getter latest_query_params : Array(DB::Any | Array(DB::Any))? = nil + + def query(sql, *params : DB::Any | Array(DB::Any)) + @latest_query_sql = sql + @latest_query_params = params.to_a + ResultSet.new + end + + def query(sql, params : Enumerable(DB::Any | Array(DB::Any))? = nil) + @latest_query_sql = sql + @latest_query_params = params.try &.to_a + ResultSet.new + end +end + +module Core + class Repository + def initialize(@db : MockDB, @logger = Logger::Dummy.new) + end + end +end + +class User + def self.from_rs(rs : MockDB::ResultSet) + [self.new] + end +end diff --git a/spec/repository/query_spec.cr b/spec/repository/query_spec.cr new file mode 100644 index 0000000..66cfbde --- /dev/null +++ b/spec/repository/query_spec.cr @@ -0,0 +1,27 @@ +require "./mock_db" + +describe Core::Repository do + db = MockDB.new + repo = Core::Repository.new(db) + + describe "#query" do + context "with Query" do + time = Time.now - 3.days + result = repo.query(Core::Query(User).new.select(:active).where("created_at > ?", time).last) + + it "calls #query with valid sql" do + db.latest_query_sql.should eq <<-SQL + SELECT users.activity_status FROM users WHERE (created_at > ?) ORDER BY users.uuid DESC LIMIT ? + SQL + end + + it "calls #query with valid params" do + db.latest_query_params.should eq [time, 1] + end + + it "returns model instance" do + result.should be_a(Array(User)) + end + end + end +end diff --git a/spec/repository/scalar_spec.cr b/spec/repository/scalar_spec.cr new file mode 100644 index 0000000..8c40600 --- /dev/null +++ b/spec/repository/scalar_spec.cr @@ -0,0 +1,36 @@ +require "./mock_db" + +describe Core::Repository do + db = MockDB.new + repo = Core::Repository.new(db) + + describe "#scalar" do + context "with paramsless Query" do + result = repo.scalar(Core::Query(User).new.update.set("foo = 42").returning(User.uuid)) + + it "calls DB#scalar with valid sql" do + db.latest_scalar_sql.should eq <<-SQL + UPDATE users SET foo = 42 RETURNING uuid + SQL + end + + it "does not pass any params to DB#scalar" do + db.latest_scalar_params.should be_nil + end + end + + context "with params Query" do + result = repo.scalar(Core::Query(User).new.update.set(active: true).returning(User.active)) + + it "calls DB#scalar with valid sql" do + db.latest_scalar_sql.should eq <<-SQL + UPDATE users SET activity_status = ? RETURNING activity_status + SQL + end + + it "pass params to DB#scalar" do + db.latest_scalar_params.should eq [true] + end + end + end +end diff --git a/spec/repository_spec.cr b/spec/repository_spec.cr deleted file mode 100644 index eba138f..0000000 --- a/spec/repository_spec.cr +++ /dev/null @@ -1,315 +0,0 @@ -require "db" -require "pg" - -require "./spec_helper" -require "../src/core/repository" -require "../src/core/schema" -require "../src/core/query" -require "../src/core/converters/enum" -require "../src/core/converters/enum_array" -require "../src/core/logger/io" - -alias Repo = Core::Repository - -db = ::DB.open(ENV["DATABASE_URL"] || raise "No DATABASE_URL is set!") -logger = Core::Logger::IO.new(STDOUT) - -module RepoSpec - class User - include Core::Schema - include Core::Validation - include Core::Query - - enum Role - User - Admin - end - - enum Permission - CreatePosts - EditPosts - end - - schema :users do - primary_key :id - - reference :referrer, User, key: :referrer_id - reference :referrals, Array(User), foreign_key: :referrer_id - - reference :posts, Array(Post), foreign_key: :author_id - reference :edited_posts, Array(Post), foreign_key: :editor_id - - field :active, Bool, db_default: true - field :role, Role, default: Role::User, converter: Core::Converters::Enum(Role) - field :name, String - field :permissions, Array(Permission), converter: Core::Converters::EnumArray(Permission, Int16), db_default: true - - field :created_at, Time, db_default: true - field :updated_at, Time? - end - end - - class Post - include Core::Schema - include Core::Validation - include Core::Query - - schema :posts do - primary_key :id - - reference :author, User, key: :author_id - reference :editor, User?, key: :editor_id - - field :the_content, String, key: :content - field :tags, Array(String)? - - field :created_at, Time, db_default: true - field :updated_at, Time? - end - end - - repo = Repo.new(db, logger) - user_created_at = uninitialized Time - - describe "#insert" do - user = User.new(name: "Test User") - - it "returns inserted user" do - user = repo.insert(user.valid!) - user.should be_truthy - end - - it "sets created_at field" do - user.created_at.should be_truthy - end - - it "doesn't set updated_at field" do - user.updated_at.should be_nil - end - - it "works with references" do - post = Post.new(author: user, the_content: "Some content", tags: ["foo", "bar"]) - repo.insert(post.valid!).should be_a(Post) - end - - it "works with multiple instances" do - users = [User.new(name: "Foo"), User.new(name: "Bar")] - repo.insert(users.map(&.valid!)).should be_a(Array(User)) - end - end - - describe "#query" do - context "with SQL" do - user = repo.query(User, "SELECT * FROM users ORDER BY id LIMIT 1").first - - it "returns a valid instance" do - user.id.should be_a(Int32) - end - end - - that_user_id = uninitialized Int64 | Int32 | Nil - - context "with Query" do - complex_query = User - .select("*", "COUNT (posts.id) AS posts_count") - .join(:posts) - .group_by("users.id", "posts.id") - .order_by("users.id", :desc) - .limit(1) - - user = repo.query(complex_query).first - that_user_id = user.id - - it "returns a valid instance" do - user.id.should be_a(Int32) - user.active.should be_true - user.role.should eq(User::Role::User) - user.name.should eq("Test User") - user.permissions.should eq [User::Permission::CreatePosts] - user.created_at.should be_a(Time) - user.updated_at.should eq(nil) - end - end - - context "with references" do - user = repo.query(User.where(id: that_user_id)).first - - it "returns models with references" do - post = repo.query(Post.where(author: user).join(:author, select: [:id, :name, :active, :role])).first - post.tags.should be_a(Array(String)) - author = post.author.not_nil! - author.should eq user - author.id.should eq that_user_id - author.active.should be_true - author.role.should eq(User::Role::User) - author.name.should eq("Test User") - author.created_at?.should be_nil - author.updated_at.should be_nil - end - end - - pending "handles DB errors" do - expect_raises do - repo.query("INVALID QUERY") - end - end - end - - describe "#query_all" do - context "with SQL" do - users = repo.query_all(User, "SELECT * FROM users WHERE id = ?", 1) - - it "returns valid instances" do - users.should be_a(Array(User)) - end - end - - context "with Query" do - users = repo.query_all(User.all) - - it "returns valid instances" do - users.should be_a(Array(User)) - end - end - end - - describe "#query_one?" do - context "with SQL" do - user = repo.query_one?(User, "SELECT * FROM users WHERE id = ?", -1) - - it "returns a valid instance" do - user.should be_a(User?) - end - end - - context "with Query" do - user = repo.query_one?(User.last) - - it "returns a valid instance" do - user.should be_a(User?) - end - end - end - - describe "#query_one" do - context "with SQL" do - user = repo.query_one(User, "SELECT * FROM users ORDER BY id DESC LIMIT 1") - - it "returns a valid instance" do - user.should be_a(User) - end - - it "raises on zero results" do - expect_raises Core::Repository::NoResultsError do - user = repo.query_one(User, "SELECT * FROM users WHERE id = ?", -1) - end - end - end - - context "with Query" do - user = repo.query_one(User.last) - - it "returns a valid instance" do - user.should be_a(User) - end - end - end - - describe "#update" do - user = repo.query_one(User.last) - - context "with Schema instance" do - it "ignores empty changes" do - repo.update(user).should eq nil - end - - user.name = "Updated User" - update = repo.update(user) - updated_user = repo.query(User.last).first - - it "updates" do - updated_user.name.should eq "Updated User" - end - end - - context "with Query instance" do - it do - update = repo.update(User.where(id: user.id).set(permissions: [User::Permission::CreatePosts, User::Permission::EditPosts])) - updated_user = repo.query_one(User.last) - updated_user.permissions.should eq [User::Permission::CreatePosts, User::Permission::EditPosts] - end - end - end - - describe "#delete" do - post = repo.query_one(Post.last) - post_id = post.id - - context "with single Schema instance" do - delete = repo.delete(post) - - it do - delete.should be_truthy - repo.query(Post.where(id: post_id)).empty?.should eq true - end - end - - context "with multiple Schema instances" do - users = repo.query(User.order_by(:created_at, :desc).limit(2)) - delete = repo.delete(users) - - it do - delete.should be_truthy - repo.query(User.where(id: users.map(&.id))).empty?.should be_true - end - end - - context "with Query instance" do - users = repo.query(User.order_by(:created_at, :desc).limit(2)) - delete = repo.delete(User.where(id: users.map(&.id))) - - it do - delete.should be_truthy - repo.query(User.where(id: users.map(&.id))).empty?.should be_true - end - end - end - - describe "#exec" do - context "with SQL" do - result = repo.exec("SELECT 'Hello world'") - - it do - result.should be_a(DB::ExecResult) - end - end - - context "with Query" do - result = repo.exec(User.all) - - it do - result.should be_a(DB::ExecResult) - end - end - end - - describe "#scalar" do - context "with SQL" do - result = repo.scalar("SELECT 1").as(Int32) - - it do - result.should eq(1) - end - end - - repo.insert(User.new(name: "Foo").valid!) - - context "with Query" do - result = repo.scalar(User.last.select(:id)).as(Int32) - - it do - result.should be_a(Int32) - end - end - end -end diff --git a/spec/schema/changes_spec.cr b/spec/schema/changes_spec.cr index a22fbb9..8fb0be0 100644 --- a/spec/schema/changes_spec.cr +++ b/spec/schema/changes_spec.cr @@ -1,65 +1,52 @@ -require "../../schema_spec" +require "../models" -module Schema::ChangesSpec - class User - include Core::Schema +describe "Schema changes" do + user = User.new - schema :users do - primary_key :id - reference :referrer, User, key: :referrer_id - field :foo, String - field :bar, Bool - end + it "has initially empty changes" do + user.changes.empty?.should be_true end - describe "#changes" do - user = User.new(id: 42, foo: "Foo", bar: false) + context "with scalar types" do + it "tracks changes" do + user.name = "Bar" + user.changes.should eq ({"name" => "Bar"}) - it "has initially empty changes" do - user.changes.empty?.should be_true + user.active = false + user.changes.should eq ({"name" => "Bar", "active" => false}) end - context "with fields" do - it "tracks changes" do - user.id = 43 - user.changes.should eq ({:id => 43}) - - user.foo = "Bar" - user.changes.should eq ({:id => 43, :foo => "Bar"}) + user.changes.clear - user.bar = true - user.changes.should eq ({:id => 43, :foo => "Bar", :bar => true}) + it "ignores when not changed" do + user.name = "Bar" + user.changes.empty?.should be_true + end + end - user.changes.clear - user.changes.empty?.should be_true - end + context "with references" do + referrer = User.new(uuid: UUID.random) + user.changes.clear - it "ignores when not changed" do - user.id = 43 - user.changes.empty?.should be_true - end + it "tracks changes" do + user.referrer = referrer + user.changes.should eq ({"referrer" => referrer}) end - context "with references" do - it "tracks changes" do - user.referrer = User.new(id: 44) - user.referrer_id.should eq 44 - user.changes.should eq ({:referrer_id => 44}) + user.changes.clear - user.referrer = nil - user.referrer_id.should eq nil - user.changes.should eq ({:referrer_id => nil}) - end + it "ignores when not changed" do + user.referrer = referrer + user.changes.empty?.should be_true + end + end - it "ignores when not changed" do - user.referrer = User.new(id: 44) - user.referrer_id.should eq 44 - user.changes.clear + context "with foreign references" do + user.changes.clear - user.referrer = User.new(id: 44) - user.referrer_id.should eq 44 - user.changes.empty?.should be_true - end + it "ignores changes" do + user.referrals = [User.new] + user.changes.empty?.should be_true end end end diff --git a/spec/schema/fields_spec.cr b/spec/schema/fields_spec.cr deleted file mode 100644 index bb06db8..0000000 --- a/spec/schema/fields_spec.cr +++ /dev/null @@ -1,73 +0,0 @@ -require "../../schema_spec" -require "../../../src/core/converters/enum" -require "../../../src/core/converters/pg/numeric" - -module Schema::FieldsSpec - class User - include Core::Schema - - enum Role - User - Admin - end - - schema :users do - primary_key :id - field :role, Role, converter: Core::Converters::Enum(Role) - field :active, Bool, db_default: true - field :foo, String, key: :foo_column, default: "Foo" - field :bar, Float64?, converter: Core::Converters::PG::Numeric - field :created_at, Time - field :updated_at, Time, nilable: true - end - end - - describe "Schema#field" do - it "gen .primary_key_field" do - User.primary_key[:name].should eq :id - end - - it "gen .primary_key_type" do - User.primary_key[:type].should eq Core::PrimaryKey - end - - user = User.new(id: 42, bar: 0.to_f64.as(Float64?)) - - it "gen #fields" do - user.fields.should eq ({ - :id => 42, - :role => nil, - :active => nil, - :foo => "Foo", - :bar => 0.to_f64, - :created_at => nil, - :updated_at => nil, - }) - end - - it "gen #primary_key_value" do - user.primary_key.should eq 42 - end - - it "gen properties" do - user.id.should eq 42 - - user.active?.should be_nil - - expect_raises Exception do - user.role - end - user.role?.should be_nil - - user.foo.should eq "Foo" - user.bar.class.should eq Float64 - - expect_raises Exception do - user.created_at - end - user.created_at?.should be_nil - - user.updated_at.should be_nil - end - end -end diff --git a/spec/schema/query_shortcuts_spec.cr b/spec/schema/query_shortcuts_spec.cr new file mode 100644 index 0000000..3955883 --- /dev/null +++ b/spec/schema/query_shortcuts_spec.cr @@ -0,0 +1,85 @@ +require "../query/**" + +describe "Schema query shortcuts" do + describe ".group_by" do + it do + User.group_by("foo", "bar").should eq Core::Query(User).new.group_by("foo", "bar") + end + end + + describe ".having" do + it do + User.having("foo").having("bar = ?", 42).should eq Core::Query(User).new.having("foo").having("bar = ?", 42) + end + end + + describe ".insert" do + it do + User.insert(name: "John").should eq Core::Query(User).new.insert(name: "John") + end + end + + describe ".limit" do + it do + User.limit(1).should eq Core::Query(User).new.limit(1) + end + end + + describe ".offset" do + it do + User.offset(1).should eq Core::Query(User).new.offset(1) + end + end + + describe ".set" do + it do + User.set(active: true).should eq Core::Query(User).new.set(active: true) + end + end + + describe ".where" do + it do + User.where(active: true).should eq Core::Query(User).new.where(active: true) + end + end + + {% for m in %w(update delete all one first last) %} + describe {{m}} do + it do + User.{{m.id}}.should eq Core::Query(User).new.{{m.id}} + end + end + {% end %} + + describe ".join" do + context "with table" do + it do + Post.join("users", "author.id = posts.author_id", as: "author").should eq Core::Query(Post).new.join("users", "author.id = posts.author_id", as: "author") + end + end + + context "with reference" do + it do + Post.join(:author, select: {'*'}).should eq Core::Query(Post).new.join(:author, select: {'*'}) + end + end + end + + describe ".order_by" do + it do + User.order_by(:uuid, :asc).order_by("foo").should eq Core::Query(User).new.order_by(:uuid, :asc).order_by("foo") + end + end + + describe ".returning" do + it do + User.returning(:name).returning('*').should eq Core::Query(User).new.returning(:name).returning('*') + end + end + + describe ".select" do + it do + User.select(:name).select('*').should eq Core::Query(User).new.select(:name).select('*') + end + end +end diff --git a/spec/schema/references_spec.cr b/spec/schema/references_spec.cr deleted file mode 100644 index f05e52b..0000000 --- a/spec/schema/references_spec.cr +++ /dev/null @@ -1,122 +0,0 @@ -require "../../schema_spec" - -module Schema::ReferencesSpec - class User - include Core::Schema - - schema :users do - primary_key :id - reference :referrer, User, key: :referrer_id - reference :referrals, Array(User), foreign_key: :referrer_id - reference :posts, Array(Post), foreign_key: :author_id - reference :likes, Array(Like), foreign_key: :user_id - end - end - - class Post - include Core::Schema - - schema :posts do - primary_key :id - reference :author, User, key: :author_id - reference :likes, Array(Like), foreign_key: :post_id - end - end - - class Like - include Core::Schema - - schema :likes do - reference :post, Post, key: :post_id - reference :user, User, key: :user_id - end - end - - describe "schema references" do - referrer = User.new(id: 42) - - user = uninitialized User - post = uninitialized Post - like = uninitialized Like - user_copy = uninitialized User - - it "add references to initializer" do - user = User.new(id: 43, referrer: referrer) - post = Post.new(id: 17, author: referrer) - like = Like.new(post: post, user: user) - user_copy = User.new(id: user.id, referrer: referrer, likes: [like]) - end - - it "add keys to fields" do - user.fields.should eq ({ - :id => 43, - :referrer_id => 42, - }) - - post.fields.should eq ({ - :id => 17, - :author_id => 42, - }) - - like.fields.should eq ({ - :post_id => 17, - :user_id => 43, - }) - - user_copy.fields.should eq ({ - :id => 43, - :referrer_id => 42, - }) - end - - it "gen named getters" do - referrer.referrer.should be_nil - referrer.referrals.should be_nil - referrer.posts.should be_nil - referrer.likes.should be_nil - - user.referrer.should eq referrer - user.referrals.should be_nil - user.posts.should be_nil - user.likes.should be_nil - - post.author.should eq referrer - post.likes.should be_nil - - like.post.should eq post - like.user.should eq user - - user_copy.likes.should eq [like] - end - - it "gen named setters" do - referrer.posts = [post] - referrer.posts.should eq [post] - - user.likes = [like] - user.likes.should eq [like] - - post.likes = [like] - post.likes.should eq [like] - - referrer.referrals = [user] - referrer.referrals.should eq [user] - - user.referrer = User.new(id: 45) - user.referrer.should_not eq referrer - end - - it "gen fields" do - referrer.referrer_id.should be_nil - - user.referrer_id.should eq 45 - post.author_id.should eq 42 - - like.user_id.should eq 43 - like.post_id.should eq 17 - - like.user_id = nil - like.user_id.should be_nil - end - end -end diff --git a/spec/schema_spec.cr b/spec/schema_spec.cr deleted file mode 100644 index ad08575..0000000 --- a/spec/schema_spec.cr +++ /dev/null @@ -1,2 +0,0 @@ -require "./spec_helper" -require "../src/core/schema" diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index b135846..8497ae7 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -1,3 +1,2 @@ require "spec" - -# alias Repo = Core::Repository +require "../src/core" diff --git a/spec/validation_spec.cr b/spec/validation_spec.cr deleted file mode 100644 index 9157811..0000000 --- a/spec/validation_spec.cr +++ /dev/null @@ -1,134 +0,0 @@ -require "./schema_spec" -require "../src/core/validation" - -module ValidationSpec - class User - include Core::Schema - include Core::Validation - - schema :users do - primary_key :id - field :name, String, validate: { - size: (1..16), - regex: /\w+/, - custom: ->(name : String) { - error!(:name, "has reserved value") if %w(foo bar baz).includes?(name) - }, - } - field :active, Bool, db_default: true - field :age, Int32, nilable: true, validate: {min!: 17} - field :height, Float64?, validate: {in: (0.5..2.5)} - field :iq, Int32?, validate: {min: 100, max!: 200} - end - - validate do - error!(:age, "cannot be greater than 150 (yet)") if age.try &.> 150 - end - end - - describe "Validation" do - describe "inline" do - user = User.new(name: "Alex") - - context "when instance is valid" do - it "passes validation" do - user.valid?.should be_true - end - end - - context ":db_default with implicit initialization" do - user = User.new(name: "Alex", explicitly_initialized: false) - - it "doesn't pass validation" do - user.validate - user.errors.should eq ([{:active => "must not be nil"}]) - end - end - - user = User.new(name: "Alex") - - describe "#name" do - it "validates presence" do - user.name = nil - user.validate - user.errors.should eq ([{:name => "must not be nil"}]) - end - - it "validates size" do - user.name = "" - user.validate - user.errors.should eq ([{:name => "must have size in range of 1..16"}]) - end - - it "validates regex" do - user.name = "%%%" - user.validate - user.errors.should eq ([{:name => "must match /w+/"}]) - end - - it "validates custom" do - user.name = "foo" - user.validate - user.errors.should eq ([{:name => "has reserved value"}]) - end - end - - describe "#age" do - user = User.new(name: "Alex", age: 20) - - it "passes validation" do - user.valid?.should be_true - end - - it "validates min!" do - user.age = 17 - user.validate - user.errors.should eq ([{:age => "must be greater than 17"}]) - end - end - - describe "#height" do - user = User.new(name: "Alex", height: 1.0) - - it "passes validation" do - user.valid?.should be_true - end - - it "validates in" do - user.height = 0.1 - user.validate - user.errors.should eq ([{:height => "must be included in (0.5..2.5)"}]) - end - end - - describe "#iq" do - user = User.new(name: "Vlad", iq: 135) - - it "passes validation" do - user.valid?.should be_true - end - - it "validates min" do - user.iq = 10 - user.validate - user.errors.should eq ([{:iq => "must be greater or equal to 100"}]) - end - - it "validates max!" do - user.iq = 200 - user.validate - user.errors.should eq ([{:iq => "must be less than 200"}]) - end - end - end - - describe "in #validate block" do - user = User.new(age: 200) - - it do - user.validate - user.errors.includes?({:age => "cannot be greater than 150 (yet)"}).should be_true - end - end - end -end diff --git a/src/core.cr b/src/core.cr index 5108b6d..583856d 100644 --- a/src/core.cr +++ b/src/core.cr @@ -1,5 +1,7 @@ +require "db" require "./core/*" +require "./core/ext/**" -# Meet `Core`, an expressive modular ORM for Crystal. +# Type-safe and expressive SQL ORM for Crystal. module Core end diff --git a/src/core/converter.cr b/src/core/converter.cr deleted file mode 100644 index 4a8ae56..0000000 --- a/src/core/converter.cr +++ /dev/null @@ -1,16 +0,0 @@ -require "db" - -module Core - # Abstract class for `DB -> Model` converters. - # - # OPTIMIZE: Make class methods abstract. See https://stackoverflow.com/questions/41651107/crystal-abstract-static-method - abstract class Converter(T) - def self.from_rs(rs) : T - raise "Not implemented!" - end - - def self.to_db(i : T) : DB::Any - raise "Not implemented!" - end - end -end diff --git a/src/core/converters/enum.cr b/src/core/converters/enum.cr deleted file mode 100644 index 6c2a21c..0000000 --- a/src/core/converters/enum.cr +++ /dev/null @@ -1,40 +0,0 @@ -require "../converter" - -module Core - module Converters - # Allows to represent integer values as Enums in models. - # - # ``` - # # SQL: - # # table users - # # column role SMALLINT - # - # require "core/converters/enum" - # - # class User - # include Core::Schema - # - # enum Role - # JustAUser - # Admin - # end - # - # schema do - # field :role, Role, converter: Core::Converters::Enum(Role) - # end - # end - # - # user.role # => User::Role::Admin - # user.insert # => INSERT INTO users (role) VALUES(1) - # ``` - class Enum(EnumClass) < Converter(Enum) - def self.from_rs(rs) - rs.read(Int16 | Int32 | Int64 | Nil).try { |v| EnumClass.new(v.to_i32) } - end - - def self.to_db(enum _enum : EnumClass) - _enum.value - end - end - end -end diff --git a/src/core/converters/enum_array.cr b/src/core/converters/enum_array.cr deleted file mode 100644 index 826c719..0000000 --- a/src/core/converters/enum_array.cr +++ /dev/null @@ -1,43 +0,0 @@ -require "../../converter" - -module Core - module Converters - # Allows to represent integer array values as Array of Enums in models. You must specify which `Int` you use in the database schema (e.g. `SMALLINT` stays for `Int16`, `INT` for `Int32`). - # - # TODO: Remove obstructing `IntClass` requirement. See https://github.com/will/crystal-pg/issues/150 - # - # ``` - # # SQL: - # # table users - # # column role SMALLINT[] - # - # require "core/converters/enum_array" - # - # class User - # include Core::Schema - # - # enum Permission - # CreatePosts - # EditPosts - # end - # - # schema do - # field :permissions, Array(Permission), converter: Core::Converters::EnumArray(Permission, Int16) - # end - # end - # - # user.permissions # => [User::Permission::CreatePosts] - # user.insert # => INSERT INTO users (permissions) VALUES('{1}') - # ``` - class EnumArray(EnumClass, IntClass) < Converter(Array(Enum)) - def self.from_rs(rs) - values = rs.read(Array(IntClass) | Nil) - values.try &.map { |v| EnumClass.new(v.to_i32) } - end - - def self.to_db(enum _enum : Array(EnumClass)) - _enum.map(&.value) - end - end - end -end diff --git a/src/core/converters/pg/numeric.cr b/src/core/converters/pg/numeric.cr deleted file mode 100644 index 03aab68..0000000 --- a/src/core/converters/pg/numeric.cr +++ /dev/null @@ -1,37 +0,0 @@ -require "../../converter" -require "pg/numeric" - -module Core - module Converters::PG - # Allows to represent `PG::Numeric` values as `Float64`s in models. - # - # ``` - # # SQL: - # # table users - # # column balance NUMERIC(16, 8) - # - # require "core/converters/pg/numeric" - # - # class User - # include Core::Schema - # include Core::Query - # - # schema do - # field :balance, Float64, converter: Core::Converters::PG::Numeric - # end - # end - # - # user = repository.query_one(User.last) - # user.balance # => 42.0 - # ``` - class Numeric < Converter(Float64) - def self.from_rs(rs) - rs.read(::PG::Numeric | Nil).try &.to_f64 - end - - def self.to_db(f : Float64) - f - end - end - end -end diff --git a/src/core/ext/db/default.cr b/src/core/ext/db/default.cr new file mode 100644 index 0000000..b3a58d6 --- /dev/null +++ b/src/core/ext/db/default.cr @@ -0,0 +1,5 @@ +module DB + # A special type that should be considered as the SQL `DEFAULT` keyword + struct Default + end +end diff --git a/src/core/ext/db_any.cr b/src/core/ext/db_any.cr new file mode 100644 index 0000000..e5fe864 --- /dev/null +++ b/src/core/ext/db_any.cr @@ -0,0 +1,15 @@ +# :nodoc: +class String + def to_db + self.as(DB::Any | Array(DB::Any)) + end +end + +{% for type in DB::TYPES.reject { |t| t.resolve == String || t.resolve == Bytes } %} + # :nodoc: + struct {{type}} + def to_db + self.as(DB::Any | Array(DB::Any)) + end + end +{% end %} diff --git a/src/core/ext/enum.cr b/src/core/ext/enum.cr new file mode 100644 index 0000000..991fe9b --- /dev/null +++ b/src/core/ext/enum.cr @@ -0,0 +1,6 @@ +# :nodoc: +struct Enum + def to_db + to_s.underscore.as(DB::Any | Array(DB::Any)) + end +end diff --git a/src/core/ext/enumerable.cr b/src/core/ext/enumerable.cr new file mode 100644 index 0000000..0575e6c --- /dev/null +++ b/src/core/ext/enumerable.cr @@ -0,0 +1,21 @@ +require "uuid" +require "uri" + +# :nodoc: +module Enumerable + def to_db(t : Enumerable(UUID).class) + map(&.to_s.as(DB::Any)) + end + + def to_db(t : Enumerable(Enum).class) + map(&.to_s.underscore.as(DB::Any)) + end + + def to_db(t : Enumerable(URI).class) + map(&.to_s.as(DB::Any)) + end + + def to_db(t : Enumerable(DB::Any).class) + map(&.as(DB::Any)) + end +end diff --git a/src/core/ext/hash.cr b/src/core/ext/hash.cr new file mode 100644 index 0000000..3c17ed3 --- /dev/null +++ b/src/core/ext/hash.cr @@ -0,0 +1,6 @@ +# :nodoc: +class Hash(K, V) + def to_db + to_json.as(DB::Any | Array(DB::Any)) + end +end diff --git a/src/core/ext/json/serializable.cr b/src/core/ext/json/serializable.cr new file mode 100644 index 0000000..ea62966 --- /dev/null +++ b/src/core/ext/json/serializable.cr @@ -0,0 +1,8 @@ +require "json" + +# :nodoc: +module JSON::Serializable + def to_db + to_json.as(DB::Any | Array(DB::Any)) + end +end diff --git a/src/core/ext/pg/result_set/json/read_serializable.cr b/src/core/ext/pg/result_set/json/read_serializable.cr new file mode 100644 index 0000000..51cd7ff --- /dev/null +++ b/src/core/ext/pg/result_set/json/read_serializable.cr @@ -0,0 +1,15 @@ +{% if @type.has_constant?("PG") %} + require "json" + + class PG::ResultSet < DB::ResultSet + def read(t : JSON::Serializable.class) + bytes = read_raw + raise "PG::ResultSet#read_raw returned 'nil'. Bytes were expected." unless bytes + t.from_json(String.new(bytes)) + end + + def read(t : (JSON::Serializable | Nil).class) + read_raw.try { |bytes| t.from_json(String.new(bytes)) } + end + end +{% end %} diff --git a/src/core/ext/pg/result_set/read_array.cr b/src/core/ext/pg/result_set/read_array.cr new file mode 100644 index 0000000..44e942d --- /dev/null +++ b/src/core/ext/pg/result_set/read_array.cr @@ -0,0 +1,38 @@ +{% if @type.has_constant?("PG") %} + class PG::ResultSet < DB::ResultSet + # TODO: Directly overload `read` with certain Array types when https://github.com/crystal-lang/crystal/issues/6701 is fixed + def read(t : Array(T).class) : Array(T) forall T + {% verbatim do %} + {% if T < Enum %} + bytes = read.as(Bytes) + String.new(bytes).delete("^a-z_\n").split("\n").map{ |s| T.parse(s) } + {% elsif T <= UUID %} + previous_def(Array(String)).map { |s| T.new(s) } + {% elsif T < DB::Any %} + previous_def(Array(T)) + {% else %} + {% raise "Unsupported (yet) Array type #{T}" %} + {% end %} + {% end %} + end + + # TODO: Directly overload `read` with certain Array types when https://github.com/crystal-lang/crystal/issues/6701 is fixed + def read(t : (Array(T)?).class) : Array(T)? forall T + {% verbatim do %} + {% if T < Enum %} + read.as(Bytes | Nil).try do |bytes| + String.new(bytes).delete("^a-z_\n").split("\n").map{ |s| T.parse(s) } + end + {% elsif T <= URI %} + previous_def(Array(String) | Nil).try &.map { |s| T.parse(s) } + {% elsif T <= UUID %} + previous_def(Array(String) | Nil).try &.map { |s| T.new(s) } + {% elsif T < DB::Any %} + previous_def(Array(T) | Nil) + {% else %} + {% raise "Unsupported (yet) Array type #{T}" %} + {% end %} + {% end %} + end + end +{% end %} diff --git a/src/core/ext/pg/result_set/read_enum.cr b/src/core/ext/pg/result_set/read_enum.cr new file mode 100644 index 0000000..9e2b7ab --- /dev/null +++ b/src/core/ext/pg/result_set/read_enum.cr @@ -0,0 +1,10 @@ +{% if @type.has_constant?("PG") %} + require "uri" + + class PG::ResultSet < DB::ResultSet + # TODO: `def read(t : (Enum | Nil).class)` leads to "can't use Enum in unions yet, use a more specific type", therefore this call is nilable + def read(t : Enum.class) + read(Bytes | Nil).try { |bytes| t.parse(String.new(bytes)) } + end + end +{% end %} diff --git a/src/core/ext/pg/result_set/read_hash.cr b/src/core/ext/pg/result_set/read_hash.cr new file mode 100644 index 0000000..e911296 --- /dev/null +++ b/src/core/ext/pg/result_set/read_hash.cr @@ -0,0 +1,10 @@ +{% if @type.has_constant?("PG") %} + require "json" + + class PG::ResultSet < DB::ResultSet + # TODO: `def read(t : (Hash | Nil).class)` leads to "can't use Hash(K, V) in unions yet, use a more specific type", therefore this call is nilable + def read(t : Hash.class) + read(String | Nil).try { |s| t.new(JSON::PullParser.new(s)) } + end + end +{% end %} diff --git a/src/core/ext/pg/result_set/read_raw.cr b/src/core/ext/pg/result_set/read_raw.cr new file mode 100644 index 0000000..d429cdd --- /dev/null +++ b/src/core/ext/pg/result_set/read_raw.cr @@ -0,0 +1,26 @@ +{% if @type.has_constant?("PG") %} + class PG::ResultSet < DB::ResultSet + def read_raw : Bytes | Nil + col_bytesize = conn.read_i32 + + if col_bytesize == -1 + @column_index += 1 + return nil + end + + sized_io = IO::Sized.new(conn.soc, col_bytesize) + + begin + slice = Bytes.new(col_bytesize) + sized_io.read_fully(slice) + ensure + conn.soc.skip(sized_io.read_remaining) if sized_io.read_remaining > 0 + @column_index += 1 + end + + slice + rescue IO::Error + raise DB::ConnectionLost.new(statement.connection) + end + end +{% end %} diff --git a/src/core/ext/pg/result_set/read_uri.cr b/src/core/ext/pg/result_set/read_uri.cr new file mode 100644 index 0000000..b419e65 --- /dev/null +++ b/src/core/ext/pg/result_set/read_uri.cr @@ -0,0 +1,13 @@ +{% if @type.has_constant?("PG") %} + require "uri" + + class PG::ResultSet < DB::ResultSet + def read(t : URI.class) + t.parse(read(String)) + end + + def read(t : (URI | Nil).class) + read(String | Nil).try { |s| URI.parse(s) } + end + end +{% end %} diff --git a/src/core/ext/pg/result_set/read_uuid.cr b/src/core/ext/pg/result_set/read_uuid.cr new file mode 100644 index 0000000..fd182bc --- /dev/null +++ b/src/core/ext/pg/result_set/read_uuid.cr @@ -0,0 +1,13 @@ +{% if @type.has_constant?("PG") %} + require "uuid" + + class PG::ResultSet < DB::ResultSet + def read(t : UUID.class) + t.new(read(String)) + end + + def read(t : (UUID | Nil).class) + read(String | Nil).try { |s| UUID.new(s) } + end + end +{% end %} diff --git a/src/core/ext/uri.cr b/src/core/ext/uri.cr new file mode 100644 index 0000000..7a6d1db --- /dev/null +++ b/src/core/ext/uri.cr @@ -0,0 +1,8 @@ +require "uri" + +# :nodoc: +class URI + def to_db + to_s.as(DB::Any | Array(DB::Any)) + end +end diff --git a/src/core/ext/uuid.cr b/src/core/ext/uuid.cr new file mode 100644 index 0000000..51db6a0 --- /dev/null +++ b/src/core/ext/uuid.cr @@ -0,0 +1,8 @@ +require "uuid" + +# :nodoc: +struct UUID + def to_db + to_s.as(DB::Any | Array(DB::Any)) + end +end diff --git a/src/core/logger.cr b/src/core/logger.cr index 3f9aa81..9339da4 100644 --- a/src/core/logger.cr +++ b/src/core/logger.cr @@ -1,4 +1,4 @@ -# Logs stuff. +# Logs back-end requests (presumably from `Repository`). abstract class Core::Logger - abstract def wrap(query : String, &block : String -> _) + abstract def wrap(data_to_log : String, &block) end diff --git a/src/core/logger/dummy.cr b/src/core/logger/dummy.cr index 9fa0863..d7d75cd 100644 --- a/src/core/logger/dummy.cr +++ b/src/core/logger/dummy.cr @@ -1,8 +1,9 @@ require "../logger" -# Does not log queries. +# Does not log anything. class Core::Logger::Dummy < Core::Logger - def wrap(query : String, &block : String -> _) - yield query + # Does nothing except yielding the *block*. + def wrap(data_to_log : String, &block) + yield end end diff --git a/src/core/logger/io.cr b/src/core/logger/io.cr index d9ac94d..0ac30f4 100644 --- a/src/core/logger/io.cr +++ b/src/core/logger/io.cr @@ -3,33 +3,37 @@ require "colorize" require "../logger" -# Logs queries into IO. +# Logs anything followed by time elapsed by the block run into the specified `IO`. +# ``` +# logger = Core::Logger::IO.new(STDOUT) +# +# result = logger.wrap("SELECT * FROM users") do +# db.query("SELECT * FROM users") +# end +# +# # => SELECT * FROM users +# # => 501μs +# ``` class Core::Logger::IO < Core::Logger - def initialize(@io : ::IO) + def initialize(@io : ::IO, @colors = true) end - # Wrap a query, logging elaped time. - # - # ``` - # wrap("SELECT * FROM users") do |q| - # db.query(q) - # end - # # => SELECT * FROM users - # # => 501μs - # ``` - def wrap(query : String, &block : String -> _) - log_query(query) + # Wrap a block, logging elapsed time and returning the result. + def wrap(data_to_log : String, &block) + log(data_to_log) started_at = Time.monotonic - r = yield(query) - log_time(Time.monotonic - started_at) - r + + result = yield + + log_elapsed(TimeFormat.auto(Time.monotonic - started_at)) + result end - protected def log_query(query) - @io << query.colorize(:blue).to_s + "\n" + protected def log(data_to_log) + @io << (@colors ? data_to_log.colorize(:blue).to_s : data_to_log) + "\n" end - protected def log_time(elapsed : Time::Span) - @io << TimeFormat.auto(elapsed).colorize(:magenta).to_s + "\n" + protected def log_elapsed(time) + @io << (@colors ? time.colorize(:magenta).to_s : time) + "\n" end end diff --git a/src/core/logger/standard.cr b/src/core/logger/standard.cr index 254137e..efaa12d 100644 --- a/src/core/logger/standard.cr +++ b/src/core/logger/standard.cr @@ -1,35 +1,43 @@ require "logger" require "colorize" require "time_format" + require "../logger" -# Logs queries into standard `::Logger` at debug level. +# Logs anything followed by elapsed time by the block into a standard `::Logger`. +# +# ``` +# logger = Logger.new(STDOUT, Logger::Severity::DEBUG) +# core_logger = Core::Logger::Standard(Logger::Severity::INFO).new(logger) +# +# result = core_logger.wrap("SELECT * FROM users") do +# db.query("SELECT * FROM users") +# end +# +# # [21:54:51:068] INFO > SELECT * FROM users +# # [21:54:51:068] INFO > 501μs +# ``` class Core::Logger::Standard < Core::Logger - def initialize(@logger : ::Logger) + def initialize(@logger : ::Logger, @log_level : ::Logger::Severity, @colors = true) end - # Wrap a query, logging elaped time at debug level. - # - # ``` - # wrap("SELECT * FROM users") do |q| - # db.query(q) - # end - # # [21:54:51:068] DEBUG > SELECT * FROM users - # # [21:54:51:068] DEBUG > 501μs - # ``` - def wrap(query : String, &block : String -> _) - log_query(query) + # Wrap a block, logging elapsed time at *log_level* and returning the result. + def wrap(data_to_log : String, &block) + log(data_to_log) started_at = Time.monotonic - r = yield(query) - log_time(Time.monotonic - started_at) - r + + result = yield + + log_elapsed(TimeFormat.auto(Time.monotonic - started_at)) + + result end - protected def log_query(query) - @logger.debug(query.colorize(:blue)) + protected def log(data_to_log) + @logger.log(@log_level, @colors ? data_to_log.colorize(:blue) : data_to_log) end - protected def log_time(elapsed : Time::Span) - @logger.debug(TimeFormat.auto(elapsed).colorize(:magenta)) + protected def log_elapsed(time) + @logger.log(@log_level, @colors ? time.colorize(:magenta) : time) end end diff --git a/src/core/params.cr b/src/core/params.cr deleted file mode 100644 index b1b7b75..0000000 --- a/src/core/params.cr +++ /dev/null @@ -1,58 +0,0 @@ -module Core - {% begin %} - {% types = %w(Bool Float32 Float64 Int32 Int64 String Time) %} - alias Param = {{types.join(" | ").id}} | Bytes | Nil | {{types.map { |t| "Array(" + t + ")" }.join(" | ").id}} - {% end %} - - # Prepare *params* for database usage. - # - # ``` - # Core.prepare_params(42, "foo") - # # => {42, "foo"} - # - # Core.prepare_params([42, 43], "foo") - # # => {[42, 43], "foo"} - # ``` - def self.prepare_params(*params) - params.map do |p| - if p.is_a?(Enumerable) - p.map &.as(Param) - else - p.as(Param) - end - end - end - - # It's a small hack to deal with recursion. See below. - # :nodoc: - def self.explicit_prepare_params(*params) - params.map do |p| - if p.is_a?(Enumerable) - p.map(&.as(Param)) - else - p.as(Param) - end - end - end - - # Prepare *params* from `Enumerable`. - # - # ``` - # Core.prepare_params([42, "foo"]) - # # => {42, "foo"} - # ``` - # - # OPTIMIZE: Strictly type with (params : ArrayLiteral | SetLiteral) - macro prepare_params(params) - {% if params.is_a?(ArrayLiteral) || params.is_a?(SetLiteral) %} - Core.prepare_params( - {% for e in params %} - {{e}}, - {% end %} - ) - {% else %} - # Dirty! But macros aren't strictly typed yet - Core.explicit_prepare_params({{params}}) - {% end %} - end -end diff --git a/src/core/primary_key.cr b/src/core/primary_key.cr deleted file mode 100644 index f05355c..0000000 --- a/src/core/primary_key.cr +++ /dev/null @@ -1,3 +0,0 @@ -module Core - alias PrimaryKey = Int32 | Int64 | Nil -end diff --git a/src/core/query.cr b/src/core/query.cr index c55f113..0aedabf 100644 --- a/src/core/query.cr +++ b/src/core/query.cr @@ -1,100 +1,169 @@ require "./query/*" module Core - # Include this module into your models: + # A powerful and type-safe SQL Query builder. Can be used either as a separate struct: # # ``` - # class User - # include Core::Schema - # include Core::Query - # - # schema :users do - # primary_key :id - # field :name, String - # end - # end - # - # repo.query_one(User.where(id: 42)) + # Core::Query(Post).new.where(id: 42) # ``` # - # Or use it separately (without module inclusion): + # or like an included module (included by default in `Core::Schema`): # # ``` - # repo.query_one(Core::Query.new(User).where(id: 42)) - # # or - # repo.query_one(Core::Query::Instance(User).new.where(id: 42)) + # Post.where(id: 42) # ``` # - # In both cases will return `Query::Instance` instances. - module Query - macro new(klass) - {{@type}}::Instance({{klass}}).new + # Queries can be either select (default), insert, update or delete, just like an actual SQL query. + # Query calls are chainable: `Post.where(id: 42).join(:author).select('*')`. + # + # Call `#to_s` to build up the Query into SQL String. `#params` are filled up while building. + struct Query(T) + # Possible query SQL types. + enum Type + Insert + Select + Update + Delete end - macro included - {% for method in %w( - group_by - having - not_having - or_having - or_not_having - and_having - and_not_having - join - inner_join - left_join - right_join - full_join - left_inner_join - right_inner_join - full_inner_join - left_outer_join - right_outer_join - full_outer_join - limit - offset - order_by - select - set - where - where_not - and_where - and_where_not - or_where - or_where_not - and - and_not - or - or_not - ) %} - # Create new `Query::Instance` and call {{method}} on it - def self.{{method.id}}(*args) - Instance(self).new.{{method.id}}(*args) - end + # This query `Type`. + property type : Type = :select - # :nodoc: - def self.{{method.id}}(**args) - Instance(self).new.{{method.id}}(**args) + # Mark this query as update one. Call `#set` afterwards. + def update + self.type = :update + self + end + + # Mark this query as delete one. You may want to call `#where` afterwards. + def delete + self.type = :delete + self + end + + # An array of DB params for this query. It's filled up only after `#to_s` call. + getter params : Array(DB::Any | Array(DB::Any)) | Nil = nil + + # Duplicates this query. + def dup + dup = self.class.new + + {% for m in %w(group_by having insert join limit offset order_by returning select set where) %} + dup.{{m.id}} = @{{m.id}}.dup + {% end %} + + return dup + end + + # Alias of `#limit(nil)`. + def all + limit(nil) + end + + # Alias of `#limit(1)`. + def one + limit(1) + end + + # Alias of `#order_by(T.primary_key, :asc).one`. + def first + order_by(T.primary_key, :asc).one + end + + # Alias of `#order_by(T.primary_key, :desc).one`. + def last + order_by(T.primary_key, :desc).one + end + + # Build this query into plain SQL string. `#params` are set after the query is built. + # + # Depending on query `#type`, a list blocks to append differs: + # + # - Insert query would append `#insert` and `#returning` clauses + # - Select query would append `#select`, `#join`, `#where`, `#group_by`, `#having`, `#order_by`, `#limit` and `#offset` clauses + # - Update query would append `#set`, `#where` and `#returning` clauses + # - Delete query would append `#where` and `#returning` clauses + # + # NOTE: When calling `Core::Repository#exec` with insert, update or delete query, its `#returning` is forced to be `nil`. Similarly, when calling `Core::Repository#query` with insert, update or delete query, `#returning` is called with `'*'` if not set before. + def to_s + unless @params.nil? + @params.not_nil!.clear + end + + query = "" + + case type + when Type::Insert + query += "INSERT INTO #{T.table}" + append_insert(query) + append_returning(query) + when Type::Select + append_select(query) + query += " FROM #{T.table}" + append_join(query) + append_where(query) + append_group_by(query) + append_having(query) + append_order_by(query) + append_limit(query) + append_offset(query) + when Type::Update + query += "UPDATE #{T.table} SET" + append_set(query) + append_where(query) + append_returning(query) + when Type::Delete + query += "DELETE FROM #{T.table}" + append_where(query) + append_returning(query) + end + + query.strip + end + + protected def ensure_params + @params = Array(DB::Any | Array(DB::Any)).new if @params.nil? + @params.not_nil! + end + + # Which clause - `WHERE` or `HAVING` was called the latest? + # :nodoc: + enum LatestWherishClause + Where + Having + end + + @latest_wherish_clause : LatestWherishClause = :where + + {% for joinder in %w(and or) %} + {% for not in [true, false] %} + # A shorthand for calling `{{joinder.id}}_where{{"_not".id if not}}` or `{{joinder.id}}_having{{"_not".id if not}}` depending on the latest call. + def {{joinder.id}}{{"_not".id if not}}(**args : **T) forall T + if @latest_wherish_clause == LatestWherishClause::Having + raise "Cannot call 'Core::Query(T)#having with named arguments'" + else + {{joinder.id}}_where{{"_not".id if not}}(**args) + end end # :nodoc: - def self.{{method.id}}(*args, **nargs) - Instance(self).new.{{method.id}}(*args, **nargs) + def {{joinder.id}}{{"_not".id if not}}(clause : String, *params) + if @latest_wherish_clause == LatestWherishClause::Having + {{joinder.id}}_having{{"_not".id if not}}(clause, *params) + else + {{joinder.id}}_where{{"_not".id if not}}(clause, *params) + end end - {% end %} - {% for method in %w( - select - update - delete - all - one - last - first) %} - def self.{{method.id}} - Instance(self).new.{{method.id}} + # :nodoc: + def {{joinder.id}}{{"_not".id if not}}(clause : String) + if @latest_wherish_clause == LatestWherishClause::Having + {{joinder.id}}_having{{"_not".id if not}}(clause) + else + {{joinder.id}}_where{{"_not".id if not}}(clause) + end end {% end %} - end + {% end %} end end diff --git a/src/core/query/group_by.cr b/src/core/query/group_by.cr new file mode 100644 index 0000000..6c468a7 --- /dev/null +++ b/src/core/query/group_by.cr @@ -0,0 +1,19 @@ +module Core + struct Query(T) + @group_by : Array(String) | Nil = nil + protected property group_by + + # Add `GROUP_BY` clause. + def group_by(*values : String) + @group_by = Array(String).new if @group_by.nil? + @group_by.not_nil!.concat(values) + self + end + + private macro append_group_by(query) + unless @group_by.nil? + {{query}} += " GROUP BY #{@group_by.not_nil!.join(", ")}" + end + end + end +end diff --git a/src/core/query/having.cr b/src/core/query/having.cr new file mode 100644 index 0000000..e5b6277 --- /dev/null +++ b/src/core/query/having.cr @@ -0,0 +1,149 @@ +module Core + struct Query(T) + struct Having + getter clause, params, or, not + + def initialize( + @clause : String, + @params : Array(DB::Any | Array(DB::Any)) | Nil = nil, + @or : Bool = false, + @not : Bool = false + ) + end + end + + @having : Array(Having) | Nil = nil + protected property having + + # Add `HAVING` *clause* with *params*. + # + # ``` + # query.having("COUNT(tags.id) > ?", 5) # HAVING (COUNT(tags.id) > ?) + # ``` + # + # Multiple calls concatenate clauses with `AND`: + # + # ``` + # query.having("COUNT(tags.id) > ?", 5).having("foo = ?", "bar") + # # HAVING (COUNT(tags.id) > ?) AND (foo = ?) + # ``` + # + # See also `#and`, `#or`, `#and_having`, `#and_having_not`, `#or_having`, `#or_having_not`. + def having(clause : String, *params : DB::Any | Array(DB::Any), or : Bool = false, not : Bool = false) + ensure_having << Having.new( + clause: clause, + params: params.to_a.map do |param| + if param.is_a?(Array) + param.map(&.as(DB::Any)) + else + param.as(DB::Any | Array(DB::Any)) + end + end, + or: or, + not: not + ) + + @latest_wherish_clause = :having + + self + end + + # Add `HAVING` *clause* without params. + # + # ``` + # query.having("COUNT(tags.id) > 5") # HAVING (COUNT(tags.id) > 5) + # ``` + # + # Multiple calls concatenate clauses with `AND`: + # + # ``` + # query.having("COUNT(tags.id) > 5").having("foo = 'bar'") + # # HAVING (COUNT(tags.id) > 5) AND (foo = 'bar') + # ``` + # + # See also `#and`, `#or`, `#and_having`, `#and_having_not`, `#or_having`, `#or_having_not`. + def having(clause : String, or : Bool = false, not : Bool = false) + ensure_having << Having.new( + clause: clause, + params: nil, + or: or, + not: not + ) + + @latest_wherish_clause = :having + + self + end + + # Add `NOT` *clause* with *params* to `HAVING`. + # + # ``` + # having_not("count = ?", 42) + # # HAVING (...) AND NOT (count = ?) + # ``` + def having_not(clause, *params) + having(clause, *params, not: true) + end + + # Add `NOT` *clause* to `HAVING`. + # + # ``` + # having_not("count = 42") + # # HAVING (...) AND NOT (count = 42) + # ``` + def having_not(clause) + having(clause, not: true) + end + + {% for or in [true, false] %} + {% for not in [true, false] %} + # Add `{{or ? "OR".id : "AND".id}}{{" NOT".id if not}}` *clause* with *params* to `HAVING`. + # + # ``` + # {{(or ? "or" : "and").id}}_having{{"_not".id if not}}("count = ?", 42) + # # HAVING (...) {{or ? "OR ".id : "AND ".id}}{{"NOT ".id if not}}(count = ?) + # ``` + def {{(or ? "or" : "and").id}}_having{{"_not".id if not}}(clause : String, *params) + having(clause, *params, or: {{or}}, not: {{not}}) + end + + # Add `{{or ? "OR".id : "AND".id}}{{" NOT".id if not}}` *clause* to `HAVING`. + # + # ``` + # {{(or ? "or" : "and").id}}_having{{"_not".id if not}}("count = 42") + # # HAVING (...) {{or ? "OR ".id : "AND ".id}}{{"NOT ".id if not}}(count = 42) + # ``` + def {{(or ? "or" : "and").id}}_having{{"_not".id if not}}(clause : String) + having(clause, or: {{or}}, not: {{not}}) + end + {% end %} + {% end %} + + protected def ensure_having + @having = Array(Having).new if @having.nil? + @having.not_nil! + end + + private macro append_having(query) + unless @having.nil? + {{query}} += " HAVING " + first_clause = true + + {{query}} += @having.not_nil!.join(" ") do |clause| + c = "" + c += (clause.or ? "OR " : "AND ") unless first_clause + c += "NOT " if clause.not + c += "(#{clause.clause})" + + first_clause = false + + unless clause.params.nil? + ensure_params.concat(clause.params.not_nil!) + end + + c + end + end + end + end +end diff --git a/src/core/query/insert.cr b/src/core/query/insert.cr new file mode 100644 index 0000000..2115b45 --- /dev/null +++ b/src/core/query/insert.cr @@ -0,0 +1,196 @@ +module Core + struct Query(T) + private struct Insert + getter name, value + + def initialize( + @name : String, + @value : DB::Any | Array(DB::Any) + ) + end + end + + @insert : Array(Insert) | Nil = nil + protected setter insert + + protected def ensure_insert + @insert = Array(Insert).new if @insert.nil? + @insert.not_nil! + end + + # Add `INSERT` clause. Marks the query as insert one. + # + # Arguments are validated at compilation time. To pass the validation, an argument type must be `<=` compared to the defined attribute type: + # + # ``` + # class User + # schema users do + # type id : Int32 = DB::Default + # type active : Bool + # end + # end + # + # User.insert(id: 42, active: true) # INSERT INTO users (id, active) VALUES (?, ?) + # User.insert(unknown: "foo") # Compilation time error + # User.insert(id: 42, active: "foo") # Compilation time error + # ``` + # + # Special value `DB::Default` is allowed too, however, it's to be skipped in the final INSERT clause: + # + # ``` + # User.insert(id: DB::Default, active: false) # INSERT INTO users (active) VALUES (?) + # ``` + # + # This method expects **all** non-nilable and non-default values to be set: + # + # ``` + # User.insert(id: 42) # Compilation time error because `active` is not set + # ``` + # + # NOTE: If all values to insert are default, please use `INSERT INTO table DEFAULT VALUES` SQL instead. + # + # TODO: Allow insert all defaults (change from skipping to `(key) VALUES (DEFAULT)`). + def insert(**values : **Values) : self forall Values + {% begin %} + {% + required_attributes = T::CORE_ATTRIBUTES.select do |a| + !a["db_nilable"] && !a["db_default"] + end.map(&.["name"]).reduce({} of Object => Bool) do |h, e| + h[e] = false; h + end + %} + + {% for key, value in Values %} + {% found = false %} + + {% for type in T::CORE_ATTRIBUTES.select(&.["key"]) %} + {% + value = value # Crystal bug. Remove it and it doesn't compile + + if key == type["name"] + if type["db_default"] + unless value <= type["type"] || value == DB::Default.class || (value.union? && value.union_types.all? { |t| [type["type"], DB::Default.class].includes?(t) }) + raise "Invalid type '#{value}' of argument '#{type["name"]}' for 'Core::Query(#{T})#insert' call. Expected: '#{type["type"]}' or 'DB::Default.class'" + end + else + unless value <= type["type"] + raise "Invalid type '#{value}' of argument '#{type["name"]}' for 'Core::Query(#{T})#insert' call. Expected: '#{type["type"]}'" + end + end + + found = true + required_attributes[key] = true + end + %} + {% end %} + + {% for type in T::CORE_REFERENCES.select { |t| t["direct"] } %} + {% + value = value # Crystal bug. Remove it and it doesn't compile + + if key == type["name"] # If key == type name (e.g. author) + if type["db_default"] + unless value <= type["type"] || value == DB::Default.class || (value.union? && value.union_types.all? { |t| [type["type"], DB::Default.class].includes?(t) }) + raise "Invalid type '#{value}' of argument '#{type["name"]}' for 'Core::Query(#{T})#insert' call. Expected: '#{type["type"]}' or 'DB::Default.class'" + end + else + unless value <= type["type"] + raise "Invalid type '#{value}' of argument '#{type["name"]}' for 'Core::Query(#{T})#insert' call. Expected: '#{type["type"]}'" + end + end + + found = true + required_attributes[key] = true + elsif key.stringify == type["key"] # If key == type key (e.g. author_id) + if type["db_default"] + unless value <= type["type"] || value == DB::Default.class || (value.union? && value.union_types.all? { |t| [type["type"], DB::Default.class].includes?(t) }) + raise "Invalid type '#{value}' of argument '#{type["name"]}' for 'Core::Query(#{T})#insert' call. Expected: '#{type["type"]}' or 'DB::Default.class'" + end + else + unless value <= type["type"].constant("PRIMARY_KEY_TYPE") + raise "Invalid type '#{value}' of argument '#{type["name"]}' for 'Core::Query(#{T})#insert' call. Expected: '#{type["type"]}'" + end + end + + found = true + required_attributes[key] = true + end + %} + {% end %} + + {% raise "Class '#{T}' doesn't have an attribute with name or key '#{key}' defined in its Schema eligible for 'Core::Query(#{T})#insert' call" unless found %} + {% end %} + + {% + unsatisfied_attributes = {} of Object => Bool + required_attributes.map { |k, v| unsatisfied_attributes[k] = v unless v; nil } + + if unsatisfied_attributes.size > 0 + raise "Class '#{T}' requires " + unsatisfied_attributes.keys.map { |a| "'#{a}'" }.join(", ") + " attribute(s) to be set on 'Core::Query(#{T})#insert' call" + end + %} + + values.each_with_index do |key, value, index| + if value.is_a?(DB::Default.class) + next # Skip if inserting DEFAULT + end + + case key + {% for type in T::CORE_ATTRIBUTES.select(&.["key"]) %} + # insert(id: 42) # "WHERE posts.id = ?", 42 + when {{type["name"].symbolize}}{{", #{type["key"].id.symbolize}".id unless type["name"].stringify == type["key"]}} + ensure_insert << Insert.new( + name: {{type["key"]}}, + value: {% if type["enumerable"] %} + value.unsafe_as({{type["type"]}}).to_db({{type["type"]}}) + {% else %} + value.unsafe_as({{type["type"]}}).to_db + {% end %} + ) + {% end %} + + {% for type in T::CORE_REFERENCES.select { |t| t["direct"] } %} + {% pk_type = type["true_type"].constant("PRIMARY_KEY_TYPE") %} + + # insert(author: user) # "WHERE posts.author_id = ?", user.primary_key + when {{type["name"].symbolize}} + ensure_insert << Insert.new( + name: {{type["key"]}}, + value: {% if type["enumerable"] %} + value.unsafe_as(Enumerable({{type["true_type"]}})).map(&.primary_key).to_db(Enumerable({{pk_type}})), + {% else %} + value.unsafe_as({{type["true_type"]}}).primary_key.to_db, + {% end %} + ) + + # insert(author_id: 42) # "WHERE posts.author_id = ?", 42 + when {{type["key"].id.symbolize}} + ensure_insert << Insert.new( + name: {{type["key"]}}, + value: {% if type["enumerable"] %} + value.unsafe_as({{pk_type}}).to_db, + {% else %} + value.unsafe_as(Enumerable({{pk_type}})).to_db(Enumerable({{pk_type}})) + {% end %} + ) + {% end %} + else + raise "Bug: unexpected key '#{key}'" + end + end + {% end %} + + self.type = :insert + self + end + + private macro append_insert(query) + if @insert.nil? || @insert.not_nil!.empty? + raise ArgumentError.new("Cannot append empty INSERT values. Ensure to call #insert before. Or if you want to insert all columns default, use 'INSERT INTO table DEFAULT VALUES' SQL query instead") + end + + {{query}} += " (#{@insert.not_nil!.map(&.name).join(", ")}) VALUES (#{@insert.not_nil!.join(", ") { '?' }})" + ensure_params.concat(@insert.not_nil!.map(&.value)) + end + end +end diff --git a/src/core/query/instance.cr b/src/core/query/instance.cr deleted file mode 100644 index 2ec7b88..0000000 --- a/src/core/query/instance.cr +++ /dev/null @@ -1,142 +0,0 @@ -require "./instance/*" - -module Core::Query - struct Instance(Schema) - # A list of params for this query. - getter params = [] of Param - protected setter params - - enum QueryType - Select - Update - Delete - end - - getter query_type = QueryType::Select - protected setter query_type - - # Reset all the values to defaults. - # - # TODO: Split to modules. Currently impossible due to https://github.com/crystal-lang/crystal/issues/5023 - def reset - params.clear - group_by_clauses.clear - having_clauses.clear - join_clauses.clear - @limit_clause = nil - @offset_clause = nil - order_by_clauses.clear - select_clauses.clear - where_clauses.clear - self - end - - # TODO: Split to modules. Currently impossible due to https://github.com/crystal-lang/crystal/issues/5023 - def clone - clone = self.class.new - clone.query_type = self.query_type - clone.group_by_clauses = self.group_by_clauses.dup - clone.having_clauses = self.having_clauses.clone - clone.join_clauses = self.join_clauses.clone - clone.limit_clause = self.limit_clause - clone.offset_clause = self.offset_clause - clone.order_by_clauses = self.order_by_clauses.dup - clone.select_clauses = self.select_clauses.dup - clone.where_clauses = self.where_clauses.clone - return clone - end - - # Mark this query as a SELECT one (default) - def select - @query_type = QueryType::Select - self - end - - # Mark this query as an UPDATE one - def update - @query_type = QueryType::Update - self - end - - # Mark this query as a DELETE one - def delete - @query_type = QueryType::Delete - self - end - - # Remove this query's `#limit` and return itself. - # - # ``` - # query = Instance(User).new.limit(3).offset(5).all.to_s - # # => SELECT * FROM users OFFSET 5 - # ``` - def all - limit(nil) - self - end - - # Sets this query limit to 1. - # - # ``` - # query = Instance(User).new.one.to_s - # # => SELECT * FROM users LIMIT 1 - # ``` - def one - limit(1) - self - end - - # Query the last row by `Model::Schema.primary_key[:name]`. - # - # ``` - # Instance(User).new.last.to_s - # # => SELECT * FROM users ORDER BY id DESC LIMIT 1 - # ``` - def last - order_by(Schema.primary_key[:name], :DESC) - one - self - end - - # Query the first row by `Model::Schema.primary_key[:name]`. - # - # ``` - # Instance(User).new.first.to_s - # # => SELECT * FROM users ORDER BY id ASC LIMIT 1 - # ``` - def first - order_by(Schema.primary_key[:name], :ASC) - one - self - end - - # Build the query, returning its SQL representation. - # - # NOTE: `#params` are empty until built. - def to_s - params.clear - query = "" - - case query_type - when QueryType::Select - append_select_clauses - query += " FROM " + Schema.table - when QueryType::Update - query += "UPDATE " + Schema.table - append_set_clauses - when QueryType::Delete - query += "DELETE FROM " + Schema.table - end - - append_join_clauses - append_where_clauses - append_group_by_clauses - append_having_clauses - append_order_by_clauses - append_limit_clause - append_offset_clause - - query.strip - end - end -end diff --git a/src/core/query/instance/group_by.cr b/src/core/query/instance/group_by.cr deleted file mode 100644 index 7450392..0000000 --- a/src/core/query/instance/group_by.cr +++ /dev/null @@ -1,20 +0,0 @@ -struct Core::Query::Instance(Schema) - # :nodoc: - protected property group_by_clauses = [] of String - - # Append values to `GROUP_BY` clause. - # - # ``` - # Core::Query::Instance(User).new.group_by("users.id").to_s - # # => SELECT users.* FROM users GROUP_BY users.id - # ``` - def group_by(*group_by) - @group_by_clauses.concat(group_by.to_a.flatten.map(&.to_s)) - self - end - - # :nodoc: - macro append_group_by_clauses - query += " GROUP BY " + group_by_clauses.join(", ") if group_by_clauses.any? - end -end diff --git a/src/core/query/instance/having.cr b/src/core/query/instance/having.cr deleted file mode 100644 index 0cdb48c..0000000 --- a/src/core/query/instance/having.cr +++ /dev/null @@ -1,172 +0,0 @@ -require "./wherish" - -struct Core::Query::Instance(Schema) - # :nodoc: - alias HavingTuple = NamedTuple(clause: String, params: Array(Param)?, or: Bool, not: Bool) - alias InternalHavingTuple = NamedTuple(clause: String, params: Array(Param)?) - - # :nodoc: - property having_clauses = [] of HavingTuple - - # Add a `HAVING` *clause*, optionally interpolated with *params*. Multiple calls will join clauses with `AND`. - # - # ``` - # query.having("char_length(name) > ?", [10]).and_not_having("created_at > NOW()").to_s - # # => HAVING (char_length(name) > ?) AND NOT (created_at > NOW()) - # ``` - def having(clause : String, params : Array | Tuple | Nil = nil, or = false, not = false) - @having_clauses << HavingTuple.new(clause: clause, params: params.try &.to_a.map(&.as(Param)), or: or, not: not) - @last_wherish_clause = :having - self - end - - # A convenient way to add `HAVING` clauses. Multiple clauses in a single call are joined with `AND`. Examples: - # - # ``` - # user = User.new(id: 42) - # query = Core::Query::Instance(Post).new - # - # # All clauses below will result in - # # "HAVING (posts.author_id = ?)" - # # with params [42] - # query.having(author: user) - # query.having(author_id: user.id) - # query.having("posts.author_id = ?", user.id) - # - # # Will result in - # # "HAVING (posts.author_id IN (?, ?) AND posts.popular = ?)" - # # with params [1, 2, true] - # query.having(author_id: [1, 2], popular: true) - # - # # Will result in - # # "HAVING NOT ((posts.author_id = ? AND posts.editor_id IS NULL))" - # # with params [42] - # query.not_having(author: user, editor: nil) - # - # # Will result in - # # "HAVING (posts.author_id = ?) OR NOT (posts.editor_id IS NOT NULL)" - # # with params [42] - # query.having(author: user).or_not_having(editor: !nil) - # - # # Will result in - # # "HAVING (users.role = ?)" - # # with params [1] - # User.having(role: User::Role::Admin) - # ``` - def having(or = false, not = false, **having) - group = [] of InternalHavingTuple - - having.to_h.tap &.each do |key, value| - {% begin %} - case key - {% for field in Schema::INTERNAL__CORE_FIELDS %} - when {{field[:key]}} - column = Schema.table + "." + {{field[:key].id.stringify}} - - if value.nil? - next group << InternalHavingTuple.new( - clause: column + " IS NULL", - params: nil, - ) - elsif value.is_a?(Enumerable) - next group << InternalHavingTuple.new( - clause: column + " IN (" + value.size.times.map { "?" }.join(", ") + ")", - params: value.map{ |v| field_to_db({{field}}, v) }, - ) - {% unless field[:type] == "Bool" %} - elsif value == true - next group << InternalHavingTuple.new( - clause: column + " IS NOT NULL", - params: nil, - ) - {% end %} - else - next group << InternalHavingTuple.new( - clause: column + " = ?", - params: Array(Param){field_to_db({{field}}, value)}, - ) - end - {% end %} - - {% for reference in Schema::INTERNAL__CORE_REFERENCES %} - when {{reference[:name]}} - column = {{Schema::TABLE.id.stringify + "." + reference[:key].id.stringify}} - - if value.nil? - next group << InternalHavingTuple.new( - clause: column + " IS NULL", - params: nil, - ) - elsif value == true - next group << InternalHavingTuple.new( - clause: column + " IS NOT NULL", - params: nil, - ) - elsif value.is_a?({{reference[:type]}}) - next group << InternalHavingTuple.new( - clause: column + " = ?", - params: [value.primary_key.as(Param)], - ) - elsif value.is_a?(Enumerable({{reference[:type]}})) - next group << InternalHavingTuple.new( - clause: column + " IN (" + value.size.times.map { "?" }.join(", ") + ")", - params: value.map &.primary_key.as(Param), - ) - else - raise ArgumentError.new("#{key} value must be either nil, true, {{reference[:class].id}} or Enumerable({{reference[:class].id}})! Given: #{value.class}") - end - {% end %} - else - raise ArgumentError.new("The key must be either reference or a field! Given: #{key}") - end - {% end %} - end - - having(group.map(&.[:clause]).join(" AND "), group.map(&.[:params]).flatten, or: or, not: not) - end - - # Add `HAVING NOT` clause. - def not_having(clause, *params) - having(clause, *params, not: true) - end - - # Add `HAVING NOT` clause. - def not_having(**having) - having(**having, not: true) - end - - {% for not in [true, false] %} - {% for or in [true, false] %} - # Add `{{or ? "OR " : "AND "}}{{"NOT " if not}}HAVING` clause. - def {{(or ? "or" : "and").id}}{{"_not".id if not}}_having(clause, *params) - having(clause, *params, or: {{or}}, not: {{not}}) - end - - # A convenient way to add `{{or ? "OR " : "AND "}}{{"NOT " if not}}HAVING` clauses. Multiple clauses in a single call are joined with `AND`. See `#having` for examples. - def {{(or ? "or" : "and").id}}{{"_not".id if not}}_having(**having) - having(**having, or: {{or}}, not: {{not}}) - end - {% end %} - {% end %} - - # :nodoc: - macro append_having_clauses - if having_clauses.any? - query += " HAVING " - first_clause = true - - query += having_clauses.join(" ") do |clause| - s = "" - s += (clause[:or] ? "OR " : "AND ") unless first_clause - s += "NOT " if clause[:not] - s += "(" + clause[:clause] + ")" - - first_clause = false - - s - end - - params.concat(having_clauses.map(&.[:params]).flatten.compact) - end - end -end diff --git a/src/core/query/instance/join.cr b/src/core/query/instance/join.cr deleted file mode 100644 index 7b6cfcf..0000000 --- a/src/core/query/instance/join.cr +++ /dev/null @@ -1,213 +0,0 @@ -struct Core::Query::Instance(Schema) - # :nodoc: - alias JoinSelectType = String | Symbol | Array(String | Symbol) | Nil - - # :nodoc: - alias JoinTuple = NamedTuple( - table: String, - on: Tuple(String, String), - "as": String, - "type": Symbol?, - "select": String | Array(String), - ) - - # :nodoc: - protected property join_clauses = [] of JoinTuple - - # Verbose `JOIN` by *table*. Selects all joined columns by default. - # - # ``` - # User.join(:posts, on: {:author_id, :id}, as: :written_posts).to_s - # # => SELECT users.* FROM users JOIN posts AS written_posts ON written_posts.author_id = users.id - # ``` - def join( - table : Symbol | String, - on : Tuple(Symbol | String, Symbol | String), - as _as : Symbol | String? = nil, - type _type : Symbol? = nil, - select _select : JoinSelectType = "*" - ) - @join_clauses.push(JoinTuple.new( - table: table.to_s, - on: on.map(&.to_s), - as: _as.try(&.to_s) || table.to_s, - type: _type, - select: _select.is_a?(Enumerable) ? _select.map(&.to_s) : _select.to_s, - )) - - self - end - - # `JOIN` *reference* - leverages schema's references' potential. - # - # - *reference* means **what** to join; - # - *as* defines alias. Default to *reference*; - # - *type* declares a joining type (e.g. :left_outer); - # - *select* specifies which fields to select (`nil` for none, `*` by default). - # - # ``` - # class User - # include Core::Schema - # include Core::Query - # - # schema :users do - # primary_key :id - # reference :authored_posts, Array(Post), foreign_key: :author_id - # reference :edited_posts, Array(Post), foreign_key: :editor_id - # end - # end - # - # class Post - # include Core::Schema - # include Core::Query - # - # schema :users do - # primary_key :id - # reference :author, User, key: :author_id - # reference :editor, User, key: :editor_id - # end - # end - # - # Post.join(:author, select: :id).to_s - # # => SELECT posts.*, '' AS _author, author.id FROM posts JOIN users AS author ON author.id = posts.author_id - # - # Post.join(:author, select: nil).where("author.id = ?", [1]).to_s - # # => SELECT posts.* FROM posts JOIN users AS author ON author.id = posts.author_id WHERE author.id = ? - # - # User.join(:authored_posts, as: :written_posts).to_s - # # => SELECT posts.*, '' as _post, written_posts.* FROM users JOIN posts AS written_posts ON users.id = written_posts.author_id - # - # Post.right_outer_join(:editor).to_s # Equal - # Query.new(Post).join(:editor, type: :right_outer).to_s # Equal - # # => SELECT posts.*, '' as _user, editor.* FROM posts RIGHT OUTER JOIN users AS editor ON editor.id = posts.editor_id - # ``` - def join(reference : Symbol, as _as : Symbol? = nil, type _type : Symbol? = nil, select _select : JoinSelectType = "*") - {% begin %} - case reference - {% for reference in Schema::INTERNAL__CORE_REFERENCES %} - when {{reference[:name]}} - on = {% if reference[:key] %} - {{reference[:key]}} - {% elsif reference[:foreign_key] %} - Schema.primary_key[:name] - {% else %} - {% raise "Reference must have either foreign_key or key" %} - {% end %} - - if _select - _mapped_select = map_select_to_field_keys({{reference[:type]}}, _select) - append_mapping_marker({{reference[:name]}}, _as || {{reference[:name]}}, _mapped_select) - end - - join( - table: {{reference[:type]}}.table, - on: { - {{reference[:foreign_key]}}, - "#{Schema.table}.#{on}", - }, - as: _as || {{reference[:name]}}, - type: _type - ) - {% end %} - else - raise "Unknown reference #{reference} for #{Schema}" - end - {% end %} - end - - # Append SELECT marker used for mapping. E.g. `append_mapping_marker("post")` would append "SELECT '' AS _post, posts.*" - def append_mapping_marker(mappable_type_name, _as, _select = "*") - if select_clauses.empty? - self.select({{Schema::TABLE.id.stringify}} + ".*") - end - - _select = if _select.is_a?(Enumerable) - _select.map { |s| "#{_as}.#{s}" }.join(", ") - else - "#{_as}.#{_select}" - end - - self.select("'' AS _#{mappable_type_name}, #{_select}") - end - - macro map_select_to_field_keys(reference_type, selects) - if {{selects}} == "*" || {{selects}} == :* - "*" - else - column_selects = Set(String).new - {% for field in reference_type.resolve.constant("INTERNAL__CORE_FIELDS") %} - if ({{selects}}.is_a?(Enumerable) && ({{selects}}.includes?({{field[:name]}}) || ({{selects}}.includes?({{field[:name].id.stringify}})))) || ({{selects}}.to_s == {{field[:name].id.stringify}}) - column_selects.add({{field[:key].id.stringify}}) - end - {% end %} - column_selects.to_a - end - end - - # `INNER JOIN` *table*. See `#join`. - def inner_join(table, on, as _as = nil, select _select = "*") - join(table, on, _as, :inner, _select) - end - - # `INNER JOIN` *reference*. See `#join`. - def inner_join(reference, as _as = nil, select _select = "*") - join(reference, _as, :inner, _select) - end - - {% for t in %i(left right full) %} - # Verbose `{{t.id.stringify.upcase.id}} JOIN` by *table*. See `#join`. - def {{t.id}}_join(table, on, as _as = nil, select _select = "*") - join(reference, on, _as, {{t}}, _select) - end - - # Verbose `{{t.id.stringify.upcase.id}} OUTER JOIN` by *table*. See `#join`. - def {{t.id}}_outer_join(table, on, as _as = nil, select _select = "*") - join(reference, on, _as, {{t}}_outer, _select) - end - - # `{{t.id.stringify.upcase.id}} JOIN` *reference*. See `#join`. - def {{t.id}}_join(reference, as _as = nil, select _select = "*") - join(reference, _as, {{t}}, _select) - end - - # `{{t.id.stringify.upcase.id}} OUTER JOIN` *reference*. See `#join`. - def {{t.id}}_outer_join(reference, as _as = nil, select _select = "*") - join(reference, _as, {{t}}_outer, _select) - end - {% end %} - - # :nodoc: - macro append_join_clauses - _join_clauses = join_clauses.map do |join| - {% begin %} - join_type = case join[:type] - when :inner - "INNER JOIN" - {% for t in %i(left right full) %} - when {{t}} - {{t.id.stringify.upcase + " JOIN"}} - when {{t}}_outer - {{t.id.stringify.upcase + " OUTER JOIN"}} - {% end %} - else - "JOIN" - end - {% end %} - - (join_type + " " + SQL_JOIN_AS_CLAUSE % { - join_table: join[:table], - alias: join[:as], - join_key: join[:on][0], - table: Schema.table, - key: join[:on][1], - }).as(String) - end - - query += " " + _join_clauses.join(" ") if _join_clauses.any? - end - - # :nodoc: - SQL_JOIN_AS_CLAUSE = <<-SQL - %{join_table} AS "%{alias}" ON "%{alias}".%{join_key} = %{key} - SQL -end diff --git a/src/core/query/instance/limit.cr b/src/core/query/instance/limit.cr deleted file mode 100644 index c74f580..0000000 --- a/src/core/query/instance/limit.cr +++ /dev/null @@ -1,20 +0,0 @@ -struct Core::Query::Instance(Schema) - # :nodoc: - protected property limit_clause : Int32 | Int64 | Nil = nil - - # Set `LIMIT` clause. - # - # ``` - # Core::Query::Instance(User).new.limit(50).to_s - # # => SELECT users.* FROM users LIMIT 50 - # ``` - def limit(limit : Int32 | Int64 | Nil) - @limit_clause = limit - self - end - - # :nodoc: - macro append_limit_clause - query += " LIMIT " + limit_clause.to_s if limit_clause - end -end diff --git a/src/core/query/instance/offset.cr b/src/core/query/instance/offset.cr deleted file mode 100644 index b62fec3..0000000 --- a/src/core/query/instance/offset.cr +++ /dev/null @@ -1,20 +0,0 @@ -struct Core::Query::Instance(Schema) - # :nodoc: - protected property offset_clause : Int32 | Int64 | Nil = nil - - # Set `OFFSET` clause. - # - # ``` - # Core::Query::Instance(User).new.offset(5).to_s - # # => SELECT users.* FROM users OFFSET 5 - # ``` - def offset(offset : Int32 | Int64 | Nil) - @offset_clause = offset - self - end - - # :nodoc: - macro append_offset_clause - query += " OFFSET " + offset_clause.to_s if offset_clause - end -end diff --git a/src/core/query/instance/order_by.cr b/src/core/query/instance/order_by.cr deleted file mode 100644 index 1516f10..0000000 --- a/src/core/query/instance/order_by.cr +++ /dev/null @@ -1,46 +0,0 @@ -struct Core::Query::Instance(Schema) - # :nodoc: - alias OrderByTuple = NamedTuple(column: String, order: String?) - - # :nodoc: - protected property order_by_clauses = [] of OrderByTuple - - # Add `ORDER BY` clause. Only `Symbol`s are accepted. - # - # ``` - # Core::Query::Instance.new(User).order_by("name", :DESC).to_s - # # => SELECT users.* FROM users ORDER BY name DESC - # ``` - def order_by(column : Symbol | String, order : Symbol | String | Nil = nil) - if column.is_a?(Symbol) - {% begin %} - case column - {% for field in Schema::INTERNAL__CORE_FIELDS %} - when {{field[:name]}} - column = {{field[:key].id.stringify}} - {% end %} - else - raise ArgumentError.new("Invalid field name #{column} for #{Schema}!") - end - {% end %} - end - - @order_by_clauses.push(OrderByTuple.new( - column: column, - order: order.try &.to_s.upcase, - )) - - self - end - - # :nodoc: - macro append_order_by_clauses - _order_by_clauses = order_by_clauses.map do |order_by_clauses| - t = order_by_clauses[:column] - t += " " + order_by_clauses[:order].not_nil! if order_by_clauses[:order] - t - end - - query += " ORDER BY " + _order_by_clauses.join(", ") if _order_by_clauses.any? - end -end diff --git a/src/core/query/instance/select.cr b/src/core/query/instance/select.cr deleted file mode 100644 index 43d6063..0000000 --- a/src/core/query/instance/select.cr +++ /dev/null @@ -1,55 +0,0 @@ -struct Core::Query::Instance(Schema) - # :nodoc: - protected property select_clauses = [] of String - - # Append values to `SELECT` clauses either by field name (Symbol) or explicitly by String, default to "*". - # - # ``` - # class User - # include Core::Schema - # include Core::Query - - # schema :users do - # primary_key :id - # field :foo, String - # field :bar, String, key: :baz - # end - # end - # - # Core::Query::Instance(User).new.select("name").to_s - # # => SELECT name FROM users - # - # User.select("DISTINCT name").select(:foo, :bar).to_s - # # => SELECT DISTINCT name, foo, baz FROM users - # - # User.select(:id).select("*") - # # => SELECT id, * FROM users - # ``` - def select(*values) - values.to_a.each do |value| - if value.is_a?(String) - select_clauses << value - elsif value.is_a?(Symbol) - {% begin %} - case value - {% for field in Schema::INTERNAL__CORE_FIELDS %} - when {{field[:name]}} - select_clauses << {{field[:key].id.stringify}} - {% end %} - else - raise ArgumentError.new("Invalid field name #{value} for #{Schema}!") - end - {% end %} - else - raise ArgumentError.new("A value to select must be either String or Symbol! Given: #{value.class}") - end - end - - self - end - - # :nodoc: - macro append_select_clauses - query += "SELECT " + (select_clauses.any? ? select_clauses.join(", ") : Schema.table.to_s + ".*") - end -end diff --git a/src/core/query/instance/set.cr b/src/core/query/instance/set.cr deleted file mode 100644 index 4be38cd..0000000 --- a/src/core/query/instance/set.cr +++ /dev/null @@ -1,63 +0,0 @@ -struct Core::Query::Instance(Schema) - private alias SetTuple = NamedTuple(clause: String, params: Array(Param)?) - protected property set_clauses = [] of SetTuple - - # Append explicit value to `SET` clauses, setting Query type to `UPDATE`. - # - # ``` - # Core::Query::Instance(User).new.update.set("popularity = floor(random() * ?)", 100).to_s - # # => UPDATE users SET popularity = floor(random() * ?) with params [100] - # ``` - def set(clause, *params) - update # Set Query type to UPDATE - - set_clauses << SetTuple.new( - clause: clause, - params: params.try &.to_a.map(&.as(Param)) - ) - - self - end - - # Append values to `SET` clauses, setting Query type to `UPDATE`. - # - # NOTE: It's not included in `Query` module to avoid confusion about possible `Schema#set` method. Use `Query#update` beforeahead. - # - # ``` - # User.update.set(active: true).to_s # Equal - # Core::Query::Instance(User).new.set(active: true).to_s # Equal - # # => UPDATE users SET active = true - # ``` - def set(**values) - values.to_h.each do |key, value| - {% begin %} - case key - {% for field in Schema::INTERNAL__CORE_FIELDS %} - when {{field[:name]}} - {% if field[:converter] %} - if value.is_a?({{field[:type]}}) - set({{field[:key].id.stringify}} + " = ?", {{field[:converter].id}}.to_db(value)) - end - {% else %} - if value.is_a?({{field[:type]}}) - set({{field[:key].id.stringify}} + " = ?", value) - end - {% end %} - {% end %} - else - raise ArgumentError.new("Invalid field name #{key} for #{Schema}!") - end - {% end %} - end - - self - end - - # :nodoc: - macro append_set_clauses - if set_clauses.any? - query += " SET " + set_clauses.map(&.[:clause]).join(", ") - params.concat(set_clauses.map(&.[:params]).flat_map(&.itself).compact) - end - end -end diff --git a/src/core/query/instance/where.cr b/src/core/query/instance/where.cr deleted file mode 100644 index 36ca39d..0000000 --- a/src/core/query/instance/where.cr +++ /dev/null @@ -1,172 +0,0 @@ -require "./wherish" - -struct Core::Query::Instance(Schema) - # :nodoc: - alias WhereTuple = NamedTuple(clause: String, params: Array(Param)?, or: Bool, not: Bool) - alias InternalWhereTuple = NamedTuple(clause: String, params: Array(Param)?) - - # :nodoc: - property where_clauses = [] of WhereTuple - - # Add a `WHERE` *clause*, optionally interpolated with *params*. Multiple calls will join clauses with `AND`. - # - # ``` - # query.where("char_length(name) > ?", [10]).and_not_where("created_at > NOW()").to_s - # # => WHERE (char_length(name) > ?) AND NOT (created_at > NOW()) - # ``` - def where(clause : String, params : Array | Tuple | Nil = nil, or = false, not = false) - @where_clauses << WhereTuple.new(clause: clause, params: params.try &.to_a.map(&.as(Param)), or: or, not: not) - @last_wherish_clause = :where - self - end - - # A convenient way to add `WHERE` clauses. Multiple clauses in a single call are joined with `AND`. Examples: - # - # ``` - # user = User.new(id: 42) - # query = Core::Query::Instance(Post).new - # - # # All clauses below will result in - # # "WHERE (posts.author_id = ?)" - # # with params [42] - # query.where(author: user) - # query.where(author_id: user.id) - # query.where("posts.author_id = ?", user.id) - # - # # Will result in - # # "WHERE (posts.author_id IN (?, ?) AND posts.popular = ?)" - # # with params [1, 2, true] - # query.where(author_id: [1, 2], popular: true) - # - # # Will result in - # # "WHERE NOT ((posts.author_id = ? AND posts.editor_id IS NULL))" - # # with params [42] - # query.not_where(author: user, editor: nil) - # - # # Will result in - # # "WHERE (posts.author_id = ?) OR NOT (posts.editor_id IS NOT NULL)" - # # with params [42] - # query.where(author: user).or_not_where(editor: !nil) - # - # # Will result in - # # "WHERE (users.role = ?)" - # # with params [1] - # User.where(role: User::Role::Admin) - # ``` - def where(or = false, not = false, **where) - group = [] of InternalWhereTuple - - where.to_h.tap &.each do |key, value| - {% begin %} - case key - {% for field in Schema::INTERNAL__CORE_FIELDS %} - when {{field[:key]}} - column = Schema.table + "." + {{field[:key].id.stringify}} - - if value.nil? - next group << InternalWhereTuple.new( - clause: column + " IS NULL", - params: nil, - ) - elsif value.is_a?(Enumerable) - next group << InternalWhereTuple.new( - clause: column + " IN (" + value.size.times.map { "?" }.join(", ") + ")", - params: value.map{ |v| field_to_db({{field}}, v) }, - ) - {% unless field[:type] == "Bool" %} - elsif value == true - next group << InternalWhereTuple.new( - clause: column + " IS NOT NULL", - params: nil, - ) - {% end %} - else - next group << InternalWhereTuple.new( - clause: column + " = ?", - params: Array(Param){field_to_db({{field}}, value)}, - ) - end - {% end %} - - {% for reference in Schema::INTERNAL__CORE_REFERENCES %} - when {{reference[:name]}} - column = {{Schema::TABLE.id.stringify + "." + reference[:key].id.stringify}} - - if value.nil? - next group << InternalWhereTuple.new( - clause: column + " IS NULL", - params: nil, - ) - elsif value == true - next group << InternalWhereTuple.new( - clause: column + " IS NOT NULL", - params: nil, - ) - elsif value.is_a?({{reference[:type]}}) - next group << InternalWhereTuple.new( - clause: column + " = ?", - params: [value.primary_key.as(Param)], - ) - elsif value.is_a?(Enumerable({{reference[:type]}})) - next group << InternalWhereTuple.new( - clause: column + " IN (" + value.size.times.map { "?" }.join(", ") + ")", - params: value.map &.primary_key.as(Param), - ) - else - raise ArgumentError.new("#{key} value must be either nil, true, {{reference[:class].id}} or Enumerable({{reference[:class].id}})! Given: #{value.class}") - end - {% end %} - else - raise ArgumentError.new("The key must be either reference or a field! Given: #{key}") - end - {% end %} - end - - where(group.map(&.[:clause]).join(" AND "), group.map(&.[:params]).flatten, or: or, not: not) - end - - # Add `WHERE NOT` clause. - def where_not(clause, *params) - where(clause, *params, not: true) - end - - # Add `WHERE NOT` clause. - def where_not(**where) - where(**where, not: true) - end - - {% for not in [true, false] %} - {% for or in [true, false] %} - # Add `{{or ? "OR " : "AND "}}{{"NOT " if not}}WHERE` clause. - def {{(or ? "or" : "and").id}}_where{{"_not".id if not}}(clause, *params) - where(clause, *params, or: {{or}}, not: {{not}}) - end - - # A convenient way to add `{{or ? "OR " : "AND "}}{{"NOT " if not}}WHERE` clauses. Multiple clauses in a single call are joined with `AND`. See `#where` for examples. - def {{(or ? "or" : "and").id}}_where{{"_not".id if not}}(**where) - where(**where, or: {{or}}, not: {{not}}) - end - {% end %} - {% end %} - - # :nodoc: - macro append_where_clauses - if where_clauses.any? - query += " WHERE " - first_clause = true - - query += where_clauses.join(" ") do |clause| - s = "" - s += (clause[:or] ? "OR " : "AND ") unless first_clause - s += "NOT " if clause[:not] - s += "(" + clause[:clause] + ")" - - first_clause = false - - s - end - - params.concat(where_clauses.map(&.[:params]).flatten.compact) - end - end -end diff --git a/src/core/query/instance/wherish.cr b/src/core/query/instance/wherish.cr deleted file mode 100644 index 51e8f09..0000000 --- a/src/core/query/instance/wherish.cr +++ /dev/null @@ -1,60 +0,0 @@ -struct Core::Query::Instance(Schema) - macro field_to_db(field, value) - {% if field[:converter] %} - if {{value}}.is_a?({{field[:type]}}) - {{field[:converter].id}}.to_db({{value}}).as(Param) - else - raise ArgumentError.new("Invalid value #{{{value}}.class} passed to {{field[:converter].id}}") - end - {% else %} - if {{value}}.is_a?(Param) - {{value}}.as(Param) - else - raise ArgumentError.new("Invalid value class #{{{value}}.class} for field {{field[:name]}}") - end - {% end %} - end - - @last_wherish_clause = :where - - {% for joinder in %w(and or) %} - {% for not in [true, false] %} - # A shorthand for calling `{{joinder.id}}_where{{"_not" if not}}` or `{{joinder.id}}_{{"not_" if not}}having` depending on the last clause call. - # - # ``` - # query.where(foo: "bar").{{joinder.id}}{{"_not" if not}}(baz: "qux") - # # => WHERE (foo = 'bar') {{joinder.upcase.id}}{{" NOT" if not}} (baz = 'qux') - # query.having(foo: "bar").{{joinder.id}}{{"_not" if not}}(baz: "qux") - # # => HAVING (foo = 'bar') {{joinder.upcase.id}}{{" NOT" if not}} (baz = 'qux') - # ``` - def {{joinder.id}}{{"_not".id if not}}(**args) - case @last_wherish_clause - when :having - {{joinder.id}}{{"_not".id if not}}_having(**args) - else - {{joinder.id}}_where{{"_not".id if not}}(**args) - end - end - - # :nodoc: - def {{joinder.id}}{{"_not".id if not}}(*args) - case @last_wherish_clause - when :having - {{joinder.id}}{{"_not".id if not}}_having(*args) - else - {{joinder.id}}_where{{"_not".id if not}}(*args) - end - end - - # :nodoc: - def {{joinder.id}}{{"_not".id if not}}(*args, **nargs) - case @last_wherish_clause - when :having - {{joinder.id}}{{"_not".id if not}}_having(*args, **nargs) - else - {{joinder.id}}_where{{"_not".id if not}}(*args, **nargs) - end - end - {% end %} - {% end %} -end diff --git a/src/core/query/join.cr b/src/core/query/join.cr new file mode 100644 index 0000000..af72038 --- /dev/null +++ b/src/core/query/join.cr @@ -0,0 +1,128 @@ +module Core + struct Query(T) + # Supported join types. + enum JoinType + Inner + Left + Right + Full + + def to_s + super.upcase + end + end + + private struct Join + getter :type, table, :alias, on + + def initialize( + @type : JoinType, + @table : String, + @alias : String | Nil, + @on : String + ) + end + end + + @join : Array(Join) | Nil = nil + protected property join + + # Add `JOIN` clause by *table* *on*. + # + # ``` + # Post.join("users", "author.id = posts.author_id", as: "author") + # # SELECT FROM posts JOIN users ON author.id = posts.author_id AS author + # ``` + def join( + table : String, + on : String, + *, + type _type : JoinType = :inner, + as _as : String | Nil = nil + ) + @join = Array(Join).new if @join.nil? + @join.not_nil! << Join.new(type: _type, table: table, alias: _as, on: on) + self + end + + # Add `JOIN` clause by *reference*. + # + # ``` + # class User + # schema users do + # pkey id : Int32 + # type posts : Array(Post), foreign_key: "author_id" + # end + # end + # + # class Post + # schema posts do + # pkey id : Int32 + # type author : User, key: "author_id" + # type content : String + # end + # end + # + # Post.join(:author) + # # SELECT posts.* FROM posts JOIN users ON posts.author_id = author.id AS author + # + # User.join(:posts, select: {Post.id, Post.content}) + # # SELECT users.*, '' AS _posts, posts.id, posts.content FROM users JOIN posts ON posts.author_id = users.id + # ``` + # + # NOTE: Direct enumerable reference joins are forbidden at the moment, e.g. you can't join `:tags` with `type tags : Array(Tag), key: "tag_ids"`. + # + # TODO: Allow to `select:` by *reference* `Attrubute`, e.g. `select: {:content}`. + def join( + reference : T::Reference, + *, + type _type : JoinType = :inner, + as _as : String = reference.to_s.underscore, + select _select : Char | String | Enumerable(String | Char) | Nil = nil + ) + on = if reference.direct? + "#{T.table}.#{reference.key} = #{_as}.#{reference.primary_key}" + else + "#{_as}.#{reference.foreign_key} = #{T.table}.#{T.primary_key.key}" + end + + if _select + self.select("'' AS _#{reference.to_s.underscore}") + + if _select.is_a?(Enumerable) + _select.each do |s| + s = "#{_as}.*" if s == '*' || s == "*" + self.select(s) + end + else + _select = "#{_as}.*" if _select == '*' || _select == "*" + self.select(_select) + end + end + + join(reference.table, on, type: _type, as: _as) + end + + {% for t in %i(left right full) %} + # Alias of `#join(table, on, type: {{t}})`. + def {{t.id}}_join(table : String, on : String,) + join(table, on, type: {{t}}) + end + + # Alias of `#join(reference, type: {{t}})`. + def {{t.id}}_join(reference : T::Reference, **nargs) + join(reference, **nargs, type: {{t}}) + end + {% end %} + + private macro append_join(query) + unless @join.nil? + {{query}} += @join.not_nil!.join(" ") do |join| + j = " #{join.type} JOIN #{join.table}" + j += (" AS #{join.alias}") if join.alias && join.table != join.alias + j += " ON #{join.on}" + end + end + end + end +end diff --git a/src/core/query/limit.cr b/src/core/query/limit.cr new file mode 100644 index 0000000..9153351 --- /dev/null +++ b/src/core/query/limit.cr @@ -0,0 +1,18 @@ +module Core + struct Query(T) + @limit : Int32 | Nil = nil + protected property limit + + # Add `LIMIT` clause. Unset with `nil`. + def limit(@limit : Int32 | Nil = nil) + self + end + + private macro append_limit(query) + if @limit + {{query}} += " LIMIT ?" + ensure_params.push(@limit.as(DB::Any)) + end + end + end +end diff --git a/src/core/query/offset.cr b/src/core/query/offset.cr new file mode 100644 index 0000000..9ff610d --- /dev/null +++ b/src/core/query/offset.cr @@ -0,0 +1,18 @@ +module Core + struct Query(T) + @offset : Int32 | Nil = nil + protected property offset + + # Add `OFFSET` clause. Unset with `nil`. + def offset(@offset : Int32 | Nil) + self + end + + private macro append_offset(query) + if @offset + {{query}} += " OFFSET ?" + ensure_params.push(@offset.as(DB::Any)) + end + end + end +end diff --git a/src/core/query/order_by.cr b/src/core/query/order_by.cr new file mode 100644 index 0000000..e39ba8b --- /dev/null +++ b/src/core/query/order_by.cr @@ -0,0 +1,55 @@ +module Core + struct Query(T) + # Possible orders for `ORDER BY` clauses. + enum Order + Asc + Desc + + def to_s + super.upcase + end + end + + private struct OrderBy + getter column, order + + def initialize( + @column : String, + @order : Order | Nil + ) + end + end + + @order_by : Array(OrderBy) | Nil = nil + protected property order_by + + # Add `ORDER BY` clause. Similar to `#select` and `#returning`, if *value* is a Schema attribute, it's checked in compile-time: + # + # ``` + # User.order_by(:uuid, :desc) # Would raise if `User` doesn't have attribute named `uuid` + # # ORDER BY uuid DESC + # + # User.order_by("foo") # Will not checked at compile-time + # # ORDER BY foo ASC + # ``` + def order_by(value : T::Attribute | String, order : Order | Nil = Order::Asc) + @order_by = Array(OrderBy).new if @order_by.nil? + @order_by.not_nil! << OrderBy.new( + column: value.is_a?(String) ? value : ("#{T.table}.#{value.key}"), + order: order, + ) + + self + end + + private macro append_order_by(query) + unless @order_by.nil? + {{query}} += " ORDER BY " + @order_by.not_nil!.join(", ") do |order_by| + o = order_by.column + o += (" #{order_by.order}") if order_by.order + o + end + end + end + end +end diff --git a/src/core/query/returning.cr b/src/core/query/returning.cr new file mode 100644 index 0000000..0f3a5b9 --- /dev/null +++ b/src/core/query/returning.cr @@ -0,0 +1,34 @@ +module Core + struct Query(T) + @returning : Array(String | Char) | Nil = nil + + # It's a public property because it's smart to allow repository to change `returning` based on query type (query, exec or scalar). + property returning + + # Add `RETURNING` clause. + # + # Similar to `#select` and `#order_by`, if *value* is a Schema attribute, it's checked in compile-time: + # + # ``` + # User.insert(name: "Foo").returning(:id) # Would raise in compile-time if `User` doesn't have attribute named `id` + # # INSERT INTO users (name) VALUES (?) RETURNING users.id + # ``` + def returning(*values : T::Attribute | String | Char) + @returning = Array(String | Char).new if @returning.nil? + @returning.not_nil!.concat(values.map do |value| + case value + when T::Attribute then "#{T.table}.#{value.key}" + else value + end + end) + + self + end + + private macro append_returning(query) + unless @returning.nil? + {{query}} += " RETURNING " + @returning.not_nil!.join(", ") + end + end + end +end diff --git a/src/core/query/select.cr b/src/core/query/select.cr new file mode 100644 index 0000000..210e07e --- /dev/null +++ b/src/core/query/select.cr @@ -0,0 +1,35 @@ +module Core + struct Query(T) + @select = [] of String | Char + protected property :select + + # Add `SELECT` clause. Marks the query as select one. + # + # Similar to `#returning` and `#order_by`, if *value* is a Schema attribute, it's checked in compile-time: + # + # ``` + # User.select(:id) # Would raise in compile-time if `User` doesn't have attribute named `id` + # # SELECT user.id FROM users + # + # User.select("foo") + # # SELECT foo FROM users + # ``` + # + # If `#select` is not called and the query type is select, a default `SELECT table.*` is appended. + def select(*values : T::Attribute | String | Char) + @select.concat(values.map do |value| + case value + when T::Attribute then "#{T.table}.#{value.key}" + else value + end + end) + + self.type = :select + self + end + + private macro append_select(query) + {{query}} += " SELECT " + (@select.empty? ? "#{T.table}.*" : @select.join(", ")) + end + end +end diff --git a/src/core/query/set.cr b/src/core/query/set.cr new file mode 100644 index 0000000..c120943 --- /dev/null +++ b/src/core/query/set.cr @@ -0,0 +1,222 @@ +module Core + struct Query(T) + private struct SetStruct + getter clause, params + + def initialize( + @clause : String, + @params : Array(DB::Any | Array(DB::Any)) | Nil + ) + end + end + + @set : Array(SetStruct) | Nil = nil + protected setter set + + # Add `SET` clause with params. + # + # Marks the query as update one, however, should be called after `#update` for readability. + # + # ``` + # User.update.set("name = ?", "foo").where(id: 42) + # # UPDATE users SET name = ? WHERE id = ? + # + # User.set("name = ?", "foo").where(id: 42) + # # ditto + # ``` + def set(clause : String, *params : DB::Any | Array(DB::Any)) + ensure_set << SetStruct.new( + clause: clause, + params: params.to_a.map do |param| + if param.is_a?(Array) + param.map(&.as(DB::Any)) + else + param.as(DB::Any | Array(DB::Any)) + end + end, + ) + + self.update + end + + # Add `SET` clause without params. + # + # Marks the query as update one, however, should be called after `#update` for readability. + # + # ``` + # User.update.set("updated_at = NOW()") + # # UPDATE users SET updated_at = NOW() + # + # User.set("updated_at = NOW()") + # # ditto + # ``` + def set(clause : String) + ensure_set << SetStruct.new( + clause: clause, + params: nil, + ) + + self.update + end + + # Add `SET` clause with named arguments. Marks the query as update one. + # + # Arguments are validated at compilation time. To pass the validation, an argument type must be `<=` compared to the defined attribute type: + # + # ``` + # class User + # schema users do + # type id : Int32 + # type active : Bool = DB::Default + # end + # end + # + # User.update.set(active: false).where(id: 42) + # # UPDATE users SET active = ? WHERE id = ? + # + # User.update.set(unknown: "foo") # Compilation time error + # User.update.set(active: "foo") # Compilation time error + # ``` + # + # Special value `DB::Default` is allowed as well: + # + # ``` + # User.update.set(active: DB::Default) # UPDATE users SET active = DEFAULT + # ``` + def set(**values : **Values) : self forall Values + {% for key, value in Values %} + {% found = false %} + + {% for type in T::CORE_ATTRIBUTES.select(&.["key"]) %} + {% + value = value # Crystal bug. Remove it and it doesn't compile + + if key == type["name"] + if type["db_default"] + unless value <= type["type"] || value == DB::Default.class || (value.union? && value.union_types.all? { |t| [type["type"], DB::Default.class].includes?(t) }) + raise "Invalid type '#{value}' of argument '#{type["name"]}' for 'Core::Query(#{T})#set' call. Expected: '#{type["type"]}' or 'DB::Default.class'" + end + else + unless value <= type["type"] + raise "Invalid type '#{value}' of argument '#{type["name"]}' for 'Core::Query(#{T})#set' call. Expected: '#{type["type"]}'" + end + end + + found = true + end + %} + {% end %} + + {% for type in T::CORE_REFERENCES.select { |t| t["direct"] } %} + {% + value = value # Crystal bug. Remove it and it doesn't compile + + if key == type["name"] # If key == type name (e.g. author) + if type["db_default"] + unless value <= type["type"] || value == DB::Default.class || (value.union? && value.union_types.all? { |t| [type["type"], DB::Default.class].includes?(t) }) + raise "Invalid type '#{value}' of argument '#{type["name"]}' for 'Core::Query(#{T})#set' call. Expected: '#{type["type"]}' or 'DB::Default.class'" + end + else + unless value <= type["type"] + raise "Invalid type '#{value}' of argument '#{type["name"]}' for 'Core::Query(#{T})#set' call. Expected: '#{type["type"]}'" + end + end + + found = true + elsif key.stringify == type["key"] # If key == type key (e.g. author_id) + if type["db_default"] + unless value <= type["type"] || value == DB::Default.class || (value.union? && value.union_types.all? { |t| [type["type"], DB::Default.class].includes?(t) }) + raise "Invalid type '#{value}' of argument '#{type["name"]}' for 'Core::Query(#{T})#set' call. Expected: '#{type["type"]}' or 'DB::Default.class'" + end + else + unless value <= type["type"] + raise "Invalid type '#{value}' of argument '#{type["name"]}' for 'Core::Query(#{T})#set' call. Expected: '#{type["type"]}'" + end + end + + found = true + end + %} + {% end %} + + {% raise "Class '#{T}' doesn't have an attribute with key '#{key}' defined in its Schema eligible for 'Core::Query(#{T})#set' call" unless found %} + {% end %} + + {% begin %} + values.each do |key, value| + case key + {% for type in T::CORE_ATTRIBUTES.select(&.["key"]) %} + # set(id: 42) # "WHERE posts.id = ?", 42 + when {{type["name"].symbolize}}{{", #{type["key"].id.symbolize}".id unless type["name"].stringify == type["key"]}} + if value.is_a?(DB::Default.class) + set({{type["key"]}} + " = DEFAULT") + else + set({{type["key"]}} + " = ?", + {% if type["enumerable"] %} + value.unsafe_as({{type["type"]}}).to_db({{type["type"]}}), + {% else %} + value.unsafe_as({{type["type"]}}).to_db, + {% end %} + ) + end + {% end %} + + # Only allow direct non-enumerable references + {% for type in T::CORE_REFERENCES.select { |t| t["direct"] } %} + {% pk_type = type["true_type"].constant("PRIMARY_KEY_TYPE") %} + + # set(author: user) # "WHERE posts.author_id = ?", user.primary_key + when {{type["name"].symbolize}} + if value.is_a?(DB::Default.class) + set({{type["key"]}} + " = DEFAULT") + else + set( + {{type["key"]}} + " = ?", + {% if type["enumerable"] %} + value.unsafe_as(Enumerable({{type["true_type"]}})).map(&.primary_key).to_db(Enumerable({{pk_type}})), + {% else %} + value.unsafe_as({{type["true_type"]}}).primary_key.to_db, + {% end %} + ) + end + + # set(author_id: 42) # "WHERE posts.author_id = ?", 42 + when {{type["key"].id.symbolize}} + if value.is_a?(DB::Default.class) + set({{type["key"]}} + " = DEFAULT") + else + set( + {{type["key"]}} + " = ?", + {% if type["enumerable"] %} + value.unsafe_as({{pk_type}}).to_db, + {% else %} + value.unsafe_as(Enumerable({{pk_type}})).to_db(Enumerable({{pk_type}})) + {% end %} + ) + end + {% end %} + else + raise "Bug: unexpected key '#{key}'" + end + end + {% end %} + + self.update + end + + protected def ensure_set + @set = Array(SetStruct).new if @set.nil? + @set.not_nil! + end + + private macro append_set(query) + if @set.nil? || ensure_set.empty? + raise ArgumentError.new("Cannot append empty SET values. Be sure to call at least one #set before") + end + + {{query}} += ' ' + @set.not_nil!.map(&.clause).join(", ") + + ensure_params.concat(@set.not_nil!.map(&.params).flat_map(&.itself).compact) + end + end +end diff --git a/src/core/query/where.cr b/src/core/query/where.cr new file mode 100644 index 0000000..e5bcf8b --- /dev/null +++ b/src/core/query/where.cr @@ -0,0 +1,304 @@ +module Core + struct Query(T) + private struct Where + getter clause, params, or, not + + def initialize( + @clause : String, + @params : Array(DB::Any | Array(DB::Any)) | Nil, + @or : Bool, + @not : Bool + ) + end + end + + @where : Array(Where) | Nil = nil + protected setter where + + # Add `WHERE` *clause* with *params*. + # + # ``` + # query.where("id = ?", 42) # WHERE (id = ?) + # ``` + # + # Multiple calls concatenate clauses with `AND`: + # + # ``` + # query.where("id = ?", 42).where("foo = ?", "bar") + # # WHERE (id = ?) AND (foo = ?) + # ``` + # + # See also `#and`, `#or`, `#and_where`, `#and_where_not`, `#or_where`, `#or_where_not`. + def where(clause : String, *params : DB::Any | Array(DB::Any), or : Bool = false, not : Bool = false) + ensure_where << Where.new( + clause: clause, + params: params.to_a.map do |param| + if param.is_a?(Array) + param.map(&.as(DB::Any)) + else + param.as(DB::Any | Array(DB::Any)) + end + end, + or: or, + not: not + ) + + @latest_wherish_clause = :where + + self + end + + # Add `WHERE` *clause* without params. + # + # ``` + # query.where("id = 42") # WHERE (id = 42) + # ``` + # + # Multiple calls concatenate clauses with `AND`: + # + # ``` + # query.where("id = ?=42").where("foo = 'bar'") + # # WHERE (id = 42) AND (foo = 'bar') + # ``` + # + # See also `#and`, `#or`, `#and_where`, `#and_where_not`, `#or_where`, `#or_where_not`. + def where(clause : String, or : Bool = false, not : Bool = false) + ensure_where << Where.new( + clause: clause, + params: nil, + or: or, + not: not + ) + + @latest_wherish_clause = :where + + self + end + + # Add `WHERE` clause with named arguments. All clauses in a single call are concatenated with `AND`. + # + # Arguments are validated at compilation time. To pass the validation, an argument type must be `<=` compared to the defined attribute type: + # + # ``` + # class User + # schema users do + # type id : Int32 + # type active : Bool = DB::Default + # type age : Int32 + # end + # end + # + # User.where(active: true, age: 18) + # # SELECT users.* FROM users WHERE (active = ? AND age = ?) + # + # User.where(unknown: "foo") # Compilation time error + # User.where(age: "foo") # Compilation time error + # ``` + # + # See also `#and`, `#or`, `#and_where`, `#and_where_not`, `#or_where`, `#or_where_not`. + def where(or : Bool = false, not : Bool = false, **values : **Values) : self forall Values + {% for key, value in Values %} + {% found = false %} + + {% for type in T::CORE_ATTRIBUTES.select(&.["key"]) %} + {% + value = value # Crystal bug. Remove it and it doesn't compile + + if key == type["name"] + unless value <= type["type"] + raise "Invalid type '#{value}' of argument '#{type["name"]}' for 'Core::Query(#{T})#where' call. Expected: '#{type["type"]}'" + end + + found = true + end + %} + {% end %} + + # Only allow direct non-enumerable references + {% for type in T::CORE_REFERENCES.select { |t| t["direct"] && !t["enumerable"] } %} + {% + value = value # Crystal bug. Remove it and it doesn't compile + + if key == type["name"] # If key == type name (e.g. author) + unless value <= type["type"] + raise "Invalid type '#{value}' of argument '#{type["name"]}' for 'Core::Query(#{T})#where' call. Expected: '#{type["type"]}'" + end + + found = true + elsif key.stringify == type["key"] # If key == type key (e.g. author_id) + unless value <= type["type"].constant("PRIMARY_KEY_TYPE") + raise "Invalid type '#{value}' of argument '#{type["name"]}' for 'Core::Query(#{T})#where' call. Expected: '#{type["type"]}'" + end + + found = true + end + %} + {% end %} + + {% raise "Class '#{T}' doesn't have an attribute with key '#{key}' defined in its Schema eligible for 'Core::Query(#{T})#where' call" unless found %} + {% end %} + + {% begin %} + internal_clauses = uninitialized String[{{Values.size}}] + internal_params = Array(DB::Any | Array(DB::Any)).new + + values.each_with_index do |key, value, index| + if value.nil? + case key + # where(id: nil) # "WHERE posts.id IS NULL" + {% for type in T::CORE_ATTRIBUTES.select(&.["key"]) %} + when {{type["name"].symbolize}}{{", #{type["key"].id.symbolize}".id unless type["name"].stringify == type["key"]}} + internal_clauses[index] = "#{T.table}.#{{{type["key"]}}} IS NULL" + {% end %} + + {% for type in T::CORE_REFERENCES.select { |t| t["direct"] && !t["enumerable"] } %} + # where(author: nil) # "WHERE posts.author_id IS NULL" + when {{type["name"].symbolize}} + internal_clauses[index] = "#{T.table}.#{{{type["key"]}}} IS NULL" + + # where(author_id: nil) # "WHERE posts.author_id IS NULL" + when {{type["key"].id.symbolize}} + internal_clauses[index] = "#{T.table}.#{{{type["key"]}}} IS NULL" + {% end %} + else + raise "Bug: unexpected key '#{key}'" + end + else + case key + {% for type in T::CORE_ATTRIBUTES.select(&.["key"]) %} + # where(id: 42) # "WHERE posts.id = ?", 42 + when {{type["name"].symbolize}}{{", #{type["key"].id.symbolize}".id unless type["name"].stringify == type["key"]}} + internal_clauses[index] = "#{T.table}.#{{{type["key"]}}} = ?" + + internal_params << {% if type["enumerable"] %} + value.unsafe_as({{type["type"]}}).to_db({{type["type"]}}) + {% else %} + value.unsafe_as({{type["type"]}}).to_db + {% end %} + {% end %} + + # Only allow direct non-enumerable references + {% for type in T::CORE_REFERENCES.select { |t| t["direct"] && !t["enumerable"] } %} + # where(author: user) # "WHERE posts.author_id = ?", user.primary_key + when {{type["name"].symbolize}} + internal_clauses[index] = "#{T.table}.#{{{type["key"]}}} = ?" + internal_params << value.unsafe_as({{type["true_type"]}}).primary_key.to_db + + # where(author_id: 42) # "WHERE posts.author_id = ?", 42 + when {{type["key"].id.symbolize}} + {% pk_type = type["true_type"].constant("PRIMARY_KEY_TYPE") %} + + internal_clauses[index] = "#{T.table}.#{{{type["key"]}}} = ?" + internal_params << value.unsafe_as({{pk_type}}).to_db + {% end %} + else + raise "Bug: unexpected key '#{key}'" + end + end + end + + ensure_where << Where.new( + clause: internal_clauses.join(" AND "), + params: internal_params, + or: or, + not: not + ) + + @latest_wherish_clause = :where + + self + {% end %} + end + + # Add `NOT` *clause* with *params* to `WHERE`. + # + # ``` + # where_not("id = ?", 42) + # # WHERE (...) AND NOT (id = ?) + # ``` + def where_not(clause, *params) + where(clause, *params, not: true) + end + + # Add `NOT` *clause* to `WHERE`. + # + # ``` + # where_not("id = 42") + # # WHERE (...) AND NOT (id = 42) + # ``` + def where_not(clause) + where(clause, not: true) + end + + # Add `NOT` clause with named arguments to `WHERE`. + # + # ``` + # where_not(id: 42) + # # WHERE (...) AND NOT (id = ?) + # ``` + def where_not(**values) + where(**values, not: true) + end + + {% for or in [true, false] %} + {% for not in [true, false] %} + # Add `{{or ? "OR".id : "AND".id}}{{" NOT".id if not}}` *clause* with *params* to `WHERE`. + # + # ``` + # {{(or ? "or" : "and").id}}_where{{"_not".id if not}}("id = ?", 42) + # # WHERE (...) {{or ? "OR ".id : "AND ".id}}{{"NOT ".id if not}}(id = ?) + # ``` + def {{(or ? "or" : "and").id}}_where{{"_not".id if not}}(clause : String, *params) + where(clause, *params, or: {{or}}, not: {{not}}) + end + + # Add `{{or ? "OR".id : "AND".id}}{{" NOT".id if not}}` *clause* to `WHERE`. + # + # ``` + # {{(or ? "or" : "and").id}}_where{{"_not".id if not}}("id = 42") + # # WHERE (...) {{or ? "OR ".id : "AND ".id}}{{"NOT ".id if not}}(id = 42) + # ``` + def {{(or ? "or" : "and").id}}_where{{"_not".id if not}}(clause : String) + where(clause, or: {{or}}, not: {{not}}) + end + + # Add `{{or ? "OR".id : "AND".id}}{{" NOT".id if not}}` clause with named arguments to `WHERE`. + # + # ``` + # {{(or ? "or" : "and").id}}_where{{"_not".id if not}}(id: 42) + # # WHERE (...) {{or ? "OR ".id : "AND ".id}}{{"NOT ".id if not}}(id = ?) + # ``` + def {{(or ? "or" : "and").id}}_where{{"_not".id if not}}(**values : **T) forall T + where(**values, or: {{or}}, not: {{not}}) + end + {% end %} + {% end %} + + protected def ensure_where + @where = Array(Where).new if @where.nil? + @where.not_nil! + end + + private macro append_where(query) + unless @where.nil? + {{query}} += " WHERE " + first_clause = true + + {{query}} += @where.not_nil!.join(" ") do |clause| + c = "" + c += (clause.or ? "OR " : "AND ") unless first_clause + c += "NOT " if clause.not + c += "(#{clause.clause})" + + first_clause = false + + unless clause.params.nil? + ensure_params.concat(clause.params.not_nil!) + end + + c + end + end + end + end +end diff --git a/src/core/repository.cr b/src/core/repository.cr index 7f9ff85..ec4a3fb 100644 --- a/src/core/repository.cr +++ b/src/core/repository.cr @@ -1,82 +1,71 @@ -require "db" -require "./params" -require "./logger/dummy" +require "./logger/*" require "./repository/*" -# `Repository` is a gateway between `Model`s and Database. -# -# Supported methods: -# -# - `#query` (alias of `#query_all`) -# - `#query_one` -# - `#query_one?` -# - `#insert` -# - `#update` -# - `#delete` -# - `#exec` -# - `#scalar` -# -# See `Query` for a handy queries builder. -# -# ``` -# logger = Core::Logger::IO.new(STDOUT) -# repo = Core::Repository.new(db, logger) -# -# user = User.new(name: "Foo") -# user = repo.insert(user) -# # INSERT INTO users (name, created_at) VALUES ($1, $2) RETURNING * -# # 1.773ms -# -# query = User.last -# user = repo.query_one(query) -# # SELECT * FROM users ORDER BY id DESC LIMIT 1 -# # 275μs -# -# user.name = "Bar" -# repo.update(user) -# # UPDATE users SET name = $1 WHERE (id = $2) RETURNING * -# # 1.578ms -# -# repo.delete(user) -# # DELETE FROM users WHERE id = $1 -# # 1.628ms -# ``` -class Core::Repository - # :nodoc: - property db - # :nodoc: - property query_logger - - include Query - include Insert - include Update - include Delete - include Exec - include Scalar +{% if @type.has_constant?("PG") %} + module Core + class Repository + # :nodoc: + PGDefined = true + end + end +{% end %} - # Initialize a new `Repository` istance linked to *db*, - # which is data storage, and *query_logger*, - # which logs Database queries. +module Core + # A gateway between models and DB. Its main features are logging, expanding `Core::Query` instances and mapping models from resulting `DB::ResultSet`. # - # NOTE: *db* and *query_logger* can be changed in the runtime with according `#db=` and `#query_logger=` methods. - def initialize(@db : ::DB::Database, @query_logger : Core::Logger = Core::Logger::Dummy.new) - end + # ``` + # repo = Core::Repository.new(DB.open(ENV["DATABASE_URL"]), Core::Logger::IO.new(STDOUT)) + # + # repo.scalar("SELECT 1").as(Int32) + # # [postgresql] SELECT 1 + # # 593μs + # + # repo.scalar("SELECT ?::int", 1).as(Int32) + # # ditto + # + # repo.query("SELECT * FROM users") # Returns raw `DB::ResultSet` + # repo.query(User, "SELECT * FROM users") # Returns `Array(User)` + # repo.query(User.all) # Returns `Array(User)` as well + # # [postgresql] SELECT users.* FROM users + # # 442μs + # # [map] User + # # 101μs + # ``` + class Repository + # A `DB::Database` instance for this repository. + property db + + # A `Core::Logger` instance for this repository. + property logger - # Prepare *query* for execution. Replaces "?" with "$i" for PostgreSQL. - def prepare_query(query : String) : String - if db.driver.is_a?(PG::Driver) - counter = 0 - query = query.as(String).gsub("?") { "$" + (counter += 1).to_s } + # Initialize the repository. + def initialize(@db : DB::Database, @logger : Core::Logger = Core::Logger::Dummy.new) end - query - end + # Prepare query for initialization. + # + # If the `#db` driver is `PG::Driver`, replace all `?` with `$1`, `$2` etc. Otherwise return *sql_query* untouched. + def prepare_query(sql_query : String) + {% if @type.has_constant?("PGDefined") %} + if db.driver.is_a?(PG::Driver) + counter = 0 + sql_query = sql_query.gsub("?") { '$' + (counter += 1).to_s } + end + {% end %} - private def now - "NOW()" - end + sql_query + end - private def default - "DEFAULT" + # Return `#db` driver name, e.g. `"postgresql"` for `PG::Driver`. + def driver_name + {% begin %} + case db.driver + {% if @type.has_constant?("PGDefined") %} + when PG::Driver then "postgresql" + {% end %} + else "sql" + end + {% end %} + end end end diff --git a/src/core/repository/delete.cr b/src/core/repository/delete.cr deleted file mode 100644 index 3657818..0000000 --- a/src/core/repository/delete.cr +++ /dev/null @@ -1,48 +0,0 @@ -class Core::Repository - module Delete - # Issue a deletion query. Basically the same as `#exec` just calling `query.delete` before. - # - # ``` - # repo.delete(User.where(id: 1)) - # # Equals to - # repo.exec(User.delete.where(id: 1)) - # ``` - def delete(query : Core::Query::Instance(T)) forall T - query.delete - exec(query.to_s, query.params) - end - - private SQL_DELETE = <<-SQL - DELETE FROM %{table_name} WHERE %{primary_key} IN (%{primary_key_values}) - SQL - - # Delete a single *instance* from Database. - # Returns `DB::ExecResult`. - # - # TODO: Handle errors. - def delete(instance : Schema) - delete([instance]) - end - - # Delete multiple *instances* from Database. - # Returns `DB::ExecResult`. - # - # TODO: Handle errors. - def delete(instances : Array(Schema)) - raise ArgumentError.new("Empty array given") if instances.empty? - - classes = instances.map(&.class).to_set - raise ArgumentError.new("Instances must be of single type, given: #{classes.join(", ")}") if classes.size > 1 - - klass = instances[0].class - - query = SQL_DELETE % { - table_name: klass.table, - primary_key: klass.primary_key[:name], - primary_key_values: instances.map { '?' }.join(", "), - } - - exec(query, instances.map(&.primary_key)) - end - end -end diff --git a/src/core/repository/exec.cr b/src/core/repository/exec.cr index 8ef3a3f..5bd1254 100644 --- a/src/core/repository/exec.cr +++ b/src/core/repository/exec.cr @@ -1,24 +1,40 @@ -class Core::Repository - module Exec - # Execute *query* and return a `DB::ExecResult`. - # - # See http://crystal-lang.github.io/crystal-db/api/0.5.0/DB/QueryMethods.html#exec%28query%2C%2Aargs%29-instance-method - # - # ``` - # repo.exec("UPDATE foo SET now = NOW()") - # ``` - def exec(query : String, *params) - query = prepare_query(query) - params = Core.prepare_params(*params) if params.any? +module Core + class Repository + # Call `db.exec(sql, *params)`. + def exec(sql : String, *params : DB::Any | Array(DB::Any)) : DB::ExecResult + sql = prepare_query(sql) - query_logger.wrap(query) do - db.exec(query, *params) + @logger.wrap("[#{driver_name}] #{sql}") do + db.exec(sql, *params) end end - # Execute *query* (after stringifying and extracting params) and return a `DB::ExecResult`. - def exec(query : Core::Query::Instance(T)) forall T - exec(query.to_s, query.params) + # Call `db.exec(sql, params)`. + def exec(sql : String, params : Enumerable(DB::Any | Array(DB::Any))? = nil) : DB::ExecResult + sql = prepare_query(sql) + + @logger.wrap("[#{driver_name}] #{sql}") do + if params + db.exec(sql, params.to_a) + else + db.exec(sql) + end + end + end + + # Build *query* and call `db.exec(query.to_s, query.params)`. + def exec(query : Query) + raise ArgumentError.new("Must not call 'Repository#exec' with SELECT Query. Consider using 'Repository#scalar' or 'Repository#query' instead") if query.type == :select + + # Removes `.returning`, so DB doesn't hang! 🍬 + query.returning = nil + sql = prepare_query(query.to_s) + + if query.params.try &.any? + exec(sql, query.params) + else + exec(sql) + end end end end diff --git a/src/core/repository/insert.cr b/src/core/repository/insert.cr deleted file mode 100644 index c2da231..0000000 --- a/src/core/repository/insert.cr +++ /dev/null @@ -1,52 +0,0 @@ -class Core::Repository - module Insert - private SQL_INSERT = <<-SQL - INSERT INTO %{table_name} (%{keys}) VALUES %{values} RETURNING * - SQL - - # Insert a single *instance* into Database. Returns `DB::ExecResult`. - # - # TODO: Handle errors. - # TODO: [RFC] Call `#query` and return `Schema` instance instead (see https://github.com/will/crystal-pg/issues/101). - def insert(instance : Schema) - insert([instance]).first - end - - # Insert multiple *instances* into Database. Returns `DB::ExecResult`. - # - # TODO: Handle errors. - # TODO: [RFC] Call `#query` and return `Schema` instance instead (see https://github.com/will/crystal-pg/issues/101). - def insert(instances : Array(Schema)) - raise ArgumentError.new("Empty array given") if instances.empty? - - classes = instances.map(&.class).to_set - raise ArgumentError.new("Instances must be of single type, given: #{classes.join(", ")}") if classes.size > 1 - - klass = instances[0].class - - instances_fields = instances.map(&.fields.dup.tap do |f| - f.each do |k, _| - next f.delete(k) if f[k].nil? && klass.fields[k][:db_default] - next f.delete(k) if k == klass.primary_key[:name] - end - end) - - inserted_columns = instances_fields[0].keys.map { |f| klass.fields[f][:key] } - - query = SQL_INSERT % { - table_name: klass.table, - keys: inserted_columns.join(", "), - values: instances_fields.map do |fields| - "(" + fields.values.map { |f| f == default ? default : "?" }.join(", ") + ")" - end.join(", "), - } - - params = instances_fields.first.values - instances_fields[1..-1].each do |fields| - params += fields.values - end - - query(klass, query, params) - end - end -end diff --git a/src/core/repository/query.cr b/src/core/repository/query.cr index 52f82e0..2a0a764 100644 --- a/src/core/repository/query.cr +++ b/src/core/repository/query.cr @@ -1,100 +1,55 @@ -class Core::Repository - module Query - # Query `#db` and return raw `DB::ResultSet`. - def query(query : String, *params) - query = prepare_query(query) - params = Core.prepare_params(*params) if params.any? - - query_logger.wrap(query) do - db.query(query, *params) +module Core + class Repository + # Call `db.query(sql, *params)`. + def query(sql : String, *params : DB::Any | Array(DB::Any)) + sql = prepare_query(sql) + + @logger.wrap("[#{driver_name}] #{sql}") do + db.query(sql, *params) end end - # Query `#db` returning an array of *model* instances. - # - # ``` - # repo.query(User, "SELECT * FROM users") # => Array(User) - # ``` - # - # TODO: Handle errors (PQ::PQError) - def query(model : Schema.class, query : String, *params) : Array - query = prepare_query(query) - params = Core.prepare_params(*params) if params.any? + # Call `db.query(sql, params)`. + def query(sql : String, params : Enumerable(DB::Any | Array(DB::Any))? = nil) + sql = prepare_query(sql) - query_logger.wrap(query) do - db.query_all(query, *params) do |rs| - rs.read(model) + @logger.wrap("[#{driver_name}] #{sql}") do + if params + db.query(sql, params.to_a) + else + db.query(sql) end end end - # ditto - def query_all(model, query, *params) - query(model, query, *params) - end - - # Query `#db` returning an array of model instances inherited from *query*. - # - # ``` - # repo.query(User.all) # => Array(User) - # ``` - # - # TODO: Handle errors (PQ::PQError) - def query(query : Core::Query::Instance(T)) forall T - query(T, query.to_s, query.params) - end - - # ditto - def query_all(query) - query(query) - end - - # Query `#db` returning a single *model* instance. - # - # ``` - # repo.query_one?(User, "SELECT * FROM users WHERE id = 1") # => User? - # ``` - def query_one?(model : Schema.class, query : String, *params) : Object - query(model, query, *params).first? - end + # Call `db.query(sql, *params)` and map the result to `Array(T)`. + def query(klass : T.class, sql : String, *params : DB::Any | Array(DB::Any)) : Array(T) forall T + rs = query(sql, *params) - # Query `#db` returning a model instance inherited from *query*. - # - # ``` - # repo.query_one?(User.first) # => User? - # ``` - def query_one?(query : Core::Query::Instance(T)) forall T - query_one?(T, query.to_s, query.params) + @logger.wrap("[map] #{{{T.name.stringify}}}") do + T.from_rs(rs) + end end - # Query `#db` returning a single *model* instance. Will raise `NoResultsError` if query returns no instances. - # - # ``` - # repo.query_one(User, "SELECT * FROM users WHERE id = 1") # => User - # ``` - def query_one(model : Schema.class, query : String, *params) : Object - query_one?(model, query, *params) || raise NoResultsError.new(model.to_s, query) - end + # Call `db.query(sql, params)` and map the result to `Array(T)`. + def query(klass : T.class, sql : String, params : Enumerable(DB::Any | Array(DB::Any))? = nil) : Array(T) forall T + rs = query(sql, params) - # Query `#db` returning a model instance inherited from *query*. Will raise `NoResultsError` if query returns no instances. - # - # ``` - # repo.query_one(User.first) # => User - # ``` - def query_one(query : Core::Query::Instance(T)) forall T - query_one(T, query.to_s, query.params) + @logger.wrap("[map] #{{{T.name.stringify}}}") do + T.from_rs(rs) + end end - # Raised if query returns zero model instances. - class NoResultsError < Exception - # TODO: Wait for https://github.com/crystal-lang/crystal/issues/5692 to be fixed - # getter model : Schema.class - - getter model_name - getter query + # Build *query*, call `db.query(sql, params)` and map the result it to `Array(T)` afterwards. + def query(query : Query(T)) : Array(T) forall T + # Adds `.returning('*')` if forgot to, so DB doesn't hang! 🍬 + query.returning = ['*'.as(String | Char)] if query.returning.nil? + sql = query.to_s - def initialize(@model_name : String, @query : String) - super("Zero #{@model_name} instances returned after query \"#{@query}\"") + if query.params.try &.any? + query(T, sql, query.params) + else + query(T, sql) end end end diff --git a/src/core/repository/scalar.cr b/src/core/repository/scalar.cr index 035de20..3e39017 100644 --- a/src/core/repository/scalar.cr +++ b/src/core/repository/scalar.cr @@ -1,24 +1,36 @@ -class Core::Repository - module Scalar - # Execute *query* and return a single scalar value. - # - # See http://crystal-lang.github.io/crystal-db/api/0.5.0/DB/QueryMethods.html#scalar%28query%2C%2Aargs%29-instance-method - # - # ``` - # repo.scalar("SELECT 1").as(Int32) - # ``` - def scalar(query : String, *params) - query = prepare_query(query) - params = Core.prepare_params(*params) if params.any? +module Core + class Repository + # Call `db.scalar(sql, *params)`. + def scalar(sql : String, *params : DB::Any | Array(DB::Any)) + sql = prepare_query(sql) - query_logger.wrap(query) do - db.scalar(query, *params) + @logger.wrap("[#{driver_name}] #{sql}") do + db.scalar(sql, *params) end end - # Execute *query* (after stringifying and extracting params) and return a single scalar value. - def scalar(query : Core::Query::Instance(T)) forall T - scalar(query.to_s, query.params) + # Call `db.scalar(sql, params)`. + def scalar(sql : String, params : Enumerable(DB::Any | Array(DB::Any))? = nil) + sql = prepare_query(sql) + + @logger.wrap("[#{driver_name}] #{sql}") do + if params + db.scalar(sql, params.to_a) + else + db.scalar(sql) + end + end + end + + # Build *query* and call `db.scalar(query.to_s, query.params)`. + def scalar(query : Query) + sql = prepare_query(query.to_s) + + if query.params.try &.any? + scalar(sql, query.params) + else + scalar(sql) + end end end end diff --git a/src/core/repository/update.cr b/src/core/repository/update.cr deleted file mode 100644 index fa6bc87..0000000 --- a/src/core/repository/update.cr +++ /dev/null @@ -1,44 +0,0 @@ -class Core::Repository - module Update - # Issue an update query. Basically the same as `#exec` just calling `query.update` before. - # - # ``` - # repo.update(User.set(active: true).where(id: 1)) - # # Equals to - # repo.exec(User.update.set(active: true).where(id: 1)) - # ``` - def update(query : Core::Query::Instance) forall T - query.update - exec(query.to_s, query.params) - end - - private SQL_UPDATE = <<-SQL - UPDATE %{table_name} SET %{set_fields} WHERE %{primary_key} = ? - SQL - - # Update single *instance*. - # Only fields appearing in `Schema#changes` are affected. - # Returns `DB::ExecResult`. - # - # NOTE: Does not check if `Schema::Validation#valid?`. - # NOTE: To update multiple instances, exec custom query (this is because instances may have different changes). - # - # TODO: Handle errors. - # TODO: [RFC] Call `#query` and return `Schema` instance instead (see https://github.com/will/crystal-pg/issues/101). - def update(instance : Schema) - fields = instance.fields.select do |k, _| - instance.changes.keys.includes?(k) - end - - return unless fields.any? - - query = SQL_UPDATE % { - table_name: instance.class.table, - set_fields: fields.keys.map { |f| instance.class.fields[f][:key] + " = ?" }.join(", "), - primary_key: instance.class.primary_key[:name], # TODO: Handle empty primary key - } - - exec(query, fields.values.push(instance.primary_key)) - end - end -end diff --git a/src/core/schema.cr b/src/core/schema.cr index 50e9288..b45a758 100644 --- a/src/core/schema.cr +++ b/src/core/schema.cr @@ -1,71 +1,117 @@ -require "db" -require "./primary_key" -require "./validation" -require "./schema/*" +require "./schema/**" module Core - # Schema defines mapping between database and model. + # This module allows to define mapping from DB to a model. # + # Be sure to fill it thouroughly with all databse columns, otherwise errors may occure. + # + # **Example:** + # + # Given SQL: + # + # ```sql + # CREATE TABLE users ( + # id SERIAL PRIMARY KEY, + # name TEXT NOT NULL, + # active BOOLEAN NOT NULL DEFAULT true, + # age INT + # ); + # + # CREATE TABLE posts ( + # id SERIAL PRIMARY KEY, + # author_id INT NOT NULL REFERENCES users (id), + # content TEXT NOT NULL, + # created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + # updated_at TIMESTAMPTZ + # ); # ``` - # class User - # include Core::Schema # - # schema "users" do - # primary_key :id + # A proper schema for it: # - # reference :referrer, self, key: :referrer_id - # reference :referrals, self, foreign_key: :referrer_id - # reference :posts, Post, foreign_key: :author_id + # ``` + # class User + # include Core::Schema # - # field :name, String - # field :age, Int32? - # field :created_at, Time, db_default: true + # schema users do + # pkey id : Int32 + # type name : String + # type active : Bool = DB::Default + # type age : Union(Int32 | Nil) + # type posts : Array(Post) # Implicit reference # end # end # # class Post # include Core::Schema # - # schema "posts" do - # primary_key :id - # reference :author, User, key: :author_id - # - # field :content, String - # field :created_at, Time, db_default: true - # field :updated_at, Time? + # schema posts do + # pkey id : Int32 + # type author : User, key: "author_id" # Explicit reference + # type content : String + # type created_at : Time = DB::Default + # type updated_at : Union(Time | Nil) # end # end # ``` + # + # Would also define special enums `Attribute` and `Reference`, e.g. `Attribute::Id` and `Reference::Author`. + # + # `Attribute` enums have `#key` method which returns table key for this attribute. + # + # `Reference` enums have multiple methods: + # + # - `#direct?` - whether is this reference direct (e.g. `true` for `Reference::Author`) + # - `#foreign?` - whether is this reference foreign (e.g. `true` for `Reference::Posts`) + # - `#table` - returns table name (e.g. `"users"` for `Reference::Author`) + # - `#key` - returns table key (e.g. `"author_id"` for `Reference::Author`) + # - `#foreign_key` - returns foreign table key (e.g. `"author_id"` for `Reference::Posts`) + # - `#primary_key` - returns table key for this reference's primary key (e.g. `"id"` for both refrences in this case) module Schema - # A basic macro for schema definition. - # All other macros have to be called **within** the *block*. - # It should only be called **once** per model. - # - # ``` - # class User - # include Core::Schema - # - # schema "users" do - # primary_key :id - # end - # end - # ``` + # Define mapping from DB to model (and vice-versa) for the *table*. macro schema(table, &block) - INTERNAL__CORE_FIELDS = [] of NamedTuple - INTERNAL__CORE_REFERENCES = [] of NamedTuple - INTERNAL__CORE_CREATED_AT_FIELDS = [] of Symbol - INTERNAL__CORE_UPDATED_AT_FIELDS = [] of Symbol + CORE_TABLE = {{table.id.stringify}} + + def self.table + {{table.id.stringify}} + end + + CORE_ATTRIBUTES = [] of NamedTuple + CORE_REFERENCES = [] of NamedTuple + + macro finished + {{yield.id}} + + define_initializer + define_changes + define_query_enums + define_query_shortcuts + define_db_mapping + end + end + + # Will be defined after `.schema` call. It would accept named arguments only, e.g. `User.new(id: 42)`. + abstract def initialize(**nargs) - {{yield}} + # A storage for instance changes; will be defined after `.schema` call. Would not track foreign references changes. + abstract def changes - define_getters({{table}}) - define_initializer - define_db_mapping - define_changes + # Method to map instances from `DB::ResultSet`; will be defined after `.schema` call. + def self.from_rs : Array(self) + {% raise NotImplementedError %} + end - {% if @type < Core::Validation %} - define_validation - {% end %} + # Would return a `Schema::Attribute` for shema's primary key; will be defined after `.schema` call. + def self.primary_key + {% raise NotImplementedError %} end + + # Would safely return instance primary key or `nil` if not set; will be defined after `.schema` call. + abstract def primary_key? + + # Would return instance primary key or raise `ArgumentError` if not set; will be defined after `.schema` call. + abstract def primary_key + + # Would check two instances for equality by their `primary_key` values; will be defined after `.schema` call. Would raise `ArgumentError` if any of instances had primary key value not set. + abstract def ==(other : self) end end diff --git a/src/core/schema/changes.cr b/src/core/schema/changes.cr index 38fe878..2a45551 100644 --- a/src/core/schema/changes.cr +++ b/src/core/schema/changes.cr @@ -1,31 +1,17 @@ -module Core - module Schema - private macro define_changes - macro finished - \{% skip_file unless INTERNAL__CORE_FIELDS.size > 0 %} +module Core::Schema + # Define `changes` getter for this schema. It will track all changes made to instance's attributes, be it a scalar attribute or a reference. + private macro define_changes + {% types = CORE_ATTRIBUTES.map(&.["type"]) + CORE_REFERENCES.select(&.["direct"]).map(&.["type"]) + [Nil] %} - # A storage for changes, empty on initialize. To reset use `changes.clear`. - @changes = Hash(Symbol, \{{INTERNAL__CORE_FIELDS.map(&.[:type]).join(" | ").id}}).new - getter changes + # A storage for changes, empty on initialization. To reset use `changes.clear`. + getter changes = Hash(String, {{types.join(" | ").id}}).new - # Track changes made to fields - \{% for field in INTERNAL__CORE_FIELDS %} - # :nodoc: - def \{{field[:name].id}}=(value : \{{field[:type].id}}) - changes[\{{field[:name]}}] = value unless @\{{field[:name].id}} == value - @\{{field[:name].id}} = value - end - \{% end %} - - # Track changes made to references, updating fields accordingly - \{% for reference in INTERNAL__CORE_REFERENCES.select { |r| r[:key] } %} - # :nodoc: - def \{{reference[:name].id}}=(value : \{{reference[:class].id}} | Nil) - self.\{{reference[:key].id}} = value.try &.\{{reference[:foreign_key].id}} - @\{{reference[:name].id}} = value - end - \{% end %} + {% for type in CORE_ATTRIBUTES + CORE_REFERENCES.select(&.["direct"]) %} + # :nodoc: + def {{type["name"]}}=(value : {{type["type"]}} | Nil) + changes[{{type["name"].stringify}}] = value unless {{type["name"]}} == value + @{{type["name"]}} = value end - end + {% end %} end end diff --git a/src/core/schema/db_mapping.cr b/src/core/schema/db_mapping.cr new file mode 100644 index 0000000..66294a3 --- /dev/null +++ b/src/core/schema/db_mapping.cr @@ -0,0 +1,133 @@ +module Core::Schema + # :nodoc: + module Mapping + # It's a class because it's value needs to be changed within recursive calls. + class ColumnIndexer + property value = 0 + end + end + + private macro define_db_mapping + # Read an array of self from [`DB::ResultSet`](http://crystal-lang.github.io/crystal-db/api/latest/DB/ResultSet.html). + def self.from_rs(rs : DB::ResultSet) : Array(self) + instances = [] of self + rs.each do + instances << self.new(rs) + end + instances + ensure + rs.close + end + + protected def initialize(rs : DB::ResultSet, is_reference = false, column_indexer : Mapping::ColumnIndexer = Mapping::ColumnIndexer.new) + @explicitly_initialized = false + + types_already_set = Hash(String, Bool).new + + while column_indexer.value < rs.column_count + column_name = rs.column_name(column_indexer.value) + + {% begin %} + case + {% for type in CORE_ATTRIBUTES %} + when column_name == {{type["key"].id.stringify}} && !types_already_set[{{type["name"].stringify}}]? + + @{{type["name"]}} = {% if type["type"] < Enum || type["type"] < Hash %} + rs.read({{type["type"]}}) + {% else %} + rs.read({{type["type"]}} | Nil) + {% end %} + + types_already_set[{{type["name"].stringify}}] = true + column_indexer.value += 1 + {% end %} + + # Initialize direct references with their primary keys only + {% for type in CORE_REFERENCES.select { |r| r["direct"] } %} + when column_name == {{type["key"].id.stringify}} && !types_already_set[{{type["name"].stringify}}]? + # Consider incoming values as an array of references' primary key values + # and try to initialize them as enumerable of instances with only primary keys set + # + # ``` + # class User + # pkey uuid : UUID + # end + # + # class Post + # type users : Array(User), key: "user_uuids" + # end + # ``` + # + # ```text + # | user_uuids | + # | ----------------- | + # | {abc-def,xyz-123} | + # ``` + # + # ``` + # post # => Post<@users=[User<@uuid="abc-def">, User<@uuid="xyz-123">]> + # ``` + {% pk = type["true_type"].constant("PRIMARY_KEY") %} + {% pk_type = type["true_type"].constant("PRIMARY_KEY_TYPE") %} + + {% if type["enumerable"] %} + @{{type["name"]}} = rs.read({{type["enumerable"]}}({{pk_type}}) | Nil).try &.map do |pk| + {{type["true_type"]}}.new({{pk}}: pk) + end + + # Consider incoming values as a reference primary key value + # and try to initialize its instance with only primary key value set + # + # ``` + # class User + # pkey uuid : UUID + # type referrer : User, key: "referrer_uuid" + # end + # ``` + # + # ```text + # | referrer_uuid | + # | ------------- | + # | abc-def-... | + # ``` + # + # ``` + # user # => User<@referrer=User<@uuid="abc-def-"> @name="..."> + # ``` + {% else %} + @{{type["name"]}} = rs.read({{pk_type}} | Nil).try { |pk| {{type["true_type"]}}.new({{pk}}: pk) } + {% end %} + + types_already_set[{{type["name"].stringify}}] = true + column_indexer.value += 1 + {% end %} + else + # Do not allow deep preloaded references (yet?) + return if is_reference + + # Preload non-enumerable references (both direct and foreign) + {% for type in CORE_REFERENCES.reject(&.["enumerable"]) %} + # Check if current column is a reference marker (e.g. "_referrer") + # Do not check for `!types_already_set` because "_referrer" takes higher precedence + if column_name == "_" + {{type["name"].stringify}} + + # Skip marker column because it doesn't have any data + rs.read + column_indexer.value += 1 + + # Read reference's attributes from further columns + @{{type["name"]}} = {{type["true_type"]}}.new(rs, true, column_indexer) + types_already_set[{{type["name"].stringify}}] = true + + next + end + {% end %} + + # Unknown column in the result set + raise DB::MappingException.new("#{{{@type}}}: cannot map column #{column_name} from a result set at index #{column_indexer.value}") + end + {% end %} + end + end + end +end diff --git a/src/core/schema/declaration.cr b/src/core/schema/declaration.cr new file mode 100644 index 0000000..346d7ab --- /dev/null +++ b/src/core/schema/declaration.cr @@ -0,0 +1,198 @@ +module Core::Schema + # Declare schema attribute or reference, it must be called within the `.schema` block: + # + # ``` + # class User + # include Core::Schema + # + # schema users do + # type id : Int32 = DB::Default, primary_key: true # It's the *primary key* + # type name : String # It's a mandatory attribute + # type age : Union(Int32 | Nil) # It's a nilable attribute + # type posts : Array(Post), foreign_key: "author_id" # It's an *implicit foreign reference* + # end + # end + # + # class Post + # include Core::Schema + # + # schema posts do + # type id : Int32 = DB::Default, primary_key: true # It's the *primary key* + # type author : User, key: "author_id" # It's a mandatory *explicit direct reference* + # type content : String # It's a mandatory attribute + # type created_at : Time = DB::Default # It's an attribute with default value set on DB side + # type updated_at : Union(Time | Nil) # It's a nilable attribute + # end + # end + # ``` + # + # Basically, `.type` mirrors the table column. It can be any Crystal type as long as it's an another `Schema` or has `#to_db` method defined. Also special `Union(Type | Nil)` is supported to mark the attribute as nilable. + # + # Supported *options*: + # + # - `primary_key` - whether is this type a primary key. There must be exactly **one** primary key for a single schema + # - `key` - table key for this type *if it differs from the name*, e.g. `type encrypted_password : String, key: "password"`. If it's present for a reference, the type is considered *direct reference* + # - `foreign_key` - foreign table key for this type, e.g. `type posts : Array(Post), foreign_key: "author_id"`. If it's present, the type is treated as *foreign reference* + macro type(declaration, **options) + {% + raise "A schema cannot contain multiple primary keys" if CORE_ATTRIBUTES.find(&.["primary_key"]) && options["primary_key"] + + unless declaration.is_a?(TypeDeclaration) + raise "Invalid schema type declaration syntax. The valid one is 'type name : Type( = default)', e.g. 'type id : Int32 = rand(100)'" + end + + enumerable = false + reference = nil + + if (type = declaration.type.resolve).union? + unless type.union_types.size == 2 && type.nilable? + raise "Only two-sized Unions with Nil (e.g. 'Int32 | Nil') are allowed on Schema attribute definition. Given: '#{declaration.type.resolve}'" + end + + type = type.union_types.find { |t| t != Nil } + + if type < Core::Schema + reference = type + elsif type < Enumerable + enumerable = type.name + + # Reference is resolved only if it is the only Enumerable type var + # E.g. `Array(User)` would be treated as reference, but `Hash(User, Int32)` would not + if (type.type_vars.size == 1 && (type = type.type_vars.first) < Core::Schema) + reference = type + end + end + else + if (type = declaration.type.resolve) < Core::Schema + reference = type + elsif (enu = declaration.type.resolve) < Enumerable + enumerable = declaration.type.name + + # Reference is resolved only if it is the only Enumerable type var + # E.g. `Array(User)` would be treated as reference, but `Hash(User, Int32)` would not + if (enu.type_vars.size == 1 && (type = enu.type_vars.first) < Core::Schema) + reference = type + end + end + end + + raise "A reference attribute cannot be a primary key" if reference && options["primary_key"] + raise "An enumerable attribute cannot be a primary key" if enumerable && options["primary_key"] + + db_nilable = declaration.type.resolve.union? && declaration.type.resolve.nilable? + db_default = declaration.value && declaration.value.resolve == DB::Default + default_instance_value = (declaration.value unless db_default) || nil + key = options["key"] || (declaration.var.stringify unless options["foreign_key"]) + %} + + getter {{declaration.var}} : {{declaration.type}} | Nil = {{default_instance_value}} + + {% if reference && options["foreign_key"] %} + property {{declaration.var}} : {{declaration.type}} | Nil = {{default_instance_value}} + {% end %} + + {% if key %} + # Return table key for `#{{declaration.var}}`. + def self.{{declaration.var}} + {{key}} + end + {% end %} + + {% if options["primary_key"] %} + PRIMARY_KEY = {{declaration.var.stringify}} + PRIMARY_KEY_TYPE = {{declaration.type.resolve}} + + # Return primary key `Attribute` enum. + def self.primary_key + Attribute::{{declaration.var.camelcase}} + end + + # Safely check for instance's primary key. Returns `nil` if not set. + def primary_key? + @{{declaration.var}} + end + + # Strictly check for instance's primary key. Raises `ArgumentError` if not set. + def primary_key + raise ArgumentError.new("{{@type}}#primary_key must be called only if primary key is set. Consider calling #primary_key? instead") if @{{declaration.var}}.nil? + return @{{declaration.var}} + end + + # Equality check between two instances by their `primary_key`. + # Raises `ArgumentError` if any of instances have primary key not set. + def ==(other : self) + self.primary_key == other.primary_key + end + {% end %} + + {% + if reference + CORE_REFERENCES.push({ + # E.g. `id` + name: declaration.var, + # Raw, as given (e.g. `Array(Post)?`) + type: declaration.type.resolve, + # Is this reference an enumerable? And if it is, which one (e.g. `Array` or `Set`)? + enumerable: enumerable, + # True reference type (e.g. `Post` for `Array(Post)`) + true_type: reference, + # Is the reference direct? + direct: !!key && !options["foreign_key"], + # Is the reference foreign? + foreign: !key && !!options["foreign_key"], + # The default value set on instance initialization + default_instance_value: default_instance_value, + # Can this reference be NULL on DB side? + db_nilable: db_nilable, + # Is this reference set to default on DB side? + db_default: db_default, + # Table key + key: key, + # Foreign table key + foreign_key: options["foreign_key"], + }) + else + CORE_ATTRIBUTES.push({ + # E.g. `id` + name: declaration.var, + # Raw, as given (e.g. `Int32`) + type: declaration.type.resolve, + # True type (if within Union, would extract it) + true_type: (declaration.type.resolve.union? ? declaration.type.resolve.union_types.find { |t| t != Nil } : type), + # Is this type an enumerable? + enumerable: enumerable, + # If the type is an enumerable and its only type var <= DB::Any + type_var_db_any: (enumerable && (declaration.type.resolve.type_vars.size) == 1 && (declaration.type.resolve.type_vars.first <= DB::Any)), + # The default value set on instance initialization + default_instance_value: default_instance_value, + # Can this attribute be NULL on DB side? + db_nilable: db_nilable, + # Is this attribute set to default on DB side? + db_default: db_default, + # Is this attribute primary key? + primary_key: options["primary_key"], + # Table key + key: key, + }) + end + %} + end + + # Declare a primary key attribute. Supported syntaxes: + # + # - `pkey id` - alias of `type id : Int32 = DB::Default, primary_key: true` + # - `pkey id : UUID` or `pkey id : UUID = UUID.random` - adds `primary_key: true` and `= DB::Default` if no default value + macro pkey(declaration, **options) + # `pkey id` + {% if declaration.is_a?(Call) %} + type({{declaration}}{{" : Int32 = DB::Default".id}}, primary_key: true, {{**options}}) + + # `pkey id : Int32 (= value)` + {% elsif declaration.is_a?(TypeDeclaration) %} + type({{declaration.var}} : {{declaration.type}} = {{declaration.value ? declaration.value : DB::Default}}, primary_key: true, {{**options}}) + + {% else %} + {% raise "Unsupported pkey definition. Possible variants are 'pkey name' or 'pkey name : Type( = default_value)'" %} + {% end %} + end +end diff --git a/src/core/schema/fields.cr b/src/core/schema/fields.cr deleted file mode 100644 index 8064b5e..0000000 --- a/src/core/schema/fields.cr +++ /dev/null @@ -1,111 +0,0 @@ -module Core - module Schema - # Define a schema field, generating properties for each field. - # - # Possible *options*: - # - *default* (`Proc?`) - Proc called for the field on instance initialization if it's `nil`; - # - *nilable* (`Bool?`) - Whether is this field nilable. Has the same effect as providing a nilable *type*. If nilable, will generate `getter!`, otherwise just `getter` (see https://crystal-lang.org/api/0.24.2/Object.html#getter%21%28%2Anames%29-macro); - # - *db_default* (`Bool?`) - Whether is this field has `DEFAULT` value handled by database. As a result, when an instance is initialized explicitly (e.g. `User.new`), this field **will not** be checked against `nil`. However, if an instance is initialized implicitly (e.g. `from_rs` or `User.new(explicitly_initialized: false)`), then this field **will** be checked against `nil`, failing validation if not nilable. - # - *primary_key* (`Bool?`) - Is this field a primary key? See `#primary_key`; - # - *key* (`Symbol?`) - Column name for this field. Defaults to *name*; - # - *converter* (`Object?`) - An object extending `Core::Converter`; - # - # ``` - # schema do - # field :active, Bool, db_default: true - # field :name, String, default: "A User", key: :name_column - # field :age, Int32? - # end - # ``` - macro field(name, type _type, **options) - {% - nilable = options[:nilable].id == "nil".id ? "#{_type}".includes?("::Nil") || "#{_type}".ends_with?("?") : options[:nilable] - %} - - @{{name.id}} : {{_type.id}} | Nil - setter {{name.id}} - getter{{"!".id unless nilable}} {{name.id}} - - {% if options[:primary_key] %} - PRIMARY_KEY = { - name: {{name}}, - type: {{_type.id}}, - } - - def self.primary_key - PRIMARY_KEY - end - - def primary_key - @{{name.id}}.not_nil! - end - - def ==(other : self) - self.primary_key == other.primary_key - end - {% end %} - - {% - converter = if options[:converter] - if options[:converter].is_a?(Generic) - (options[:converter].name.resolve.stringify.gsub(/\([\w, ]+\)/, "(" + options[:converter].type_vars.map { |v| v.resolve.stringify }.join(", ") + ")")).id - else - options[:converter] - end - else - nil - end - %} - - {% INTERNAL__CORE_FIELDS.push({ - name: name, - type: (if _type.is_a?(Generic) - if ("#{_type.name.resolve}" == "Array(T)") - _type.name.resolve.stringify.gsub(/\([\w, ]+\)/, "(" + _type.type_vars.map do |v| - v.resolve.stringify - end.join(", ") + ")").id - else - _type - end - else - _type.resolve - end), - nilable: nilable, - db_default: !!options[:db_default], - default: options[:default], - converter: converter, - key: options[:key] || name, - options: options.empty? ? nil : options, - }) %} - end - - # Define a primary key field. - # - # ``` - # class User - # include Core::Schema - # - # schema do - # primary_key :id - # # Is an alias of - # field :id, Core::PrimaryKey?, primary_key: true - # end - # end - # - # User.primary_key # => :id - # user = User.new(id: 42) - # user.primary_key_value # => 42 - # ``` - # - # The *type* is `Core::PrimaryKey?` by default, but you pass whatever you want: - # - # ``` - # schema do - # primary_key :uuid, String, default: SecureRandom.uuid - # end - # ``` - macro primary_key(name, type _type = Core::PrimaryKey?, **options) - field({{name}}, {{_type}}, primary_key: true, {{**options}}) - end - end -end diff --git a/src/core/schema/getters.cr b/src/core/schema/getters.cr deleted file mode 100644 index 81b1226..0000000 --- a/src/core/schema/getters.cr +++ /dev/null @@ -1,73 +0,0 @@ -module Core - module Schema - private macro define_getters(table) - macro finished - TABLE = {{table}} - - def self.table - TABLE.to_s - end - - \{% if INTERNAL__CORE_FIELDS.size > 0 %} - FIELDS = { - \{% for field in INTERNAL__CORE_FIELDS %} - \{{field[:name]}} => { - type: \{{field[:type].id}}, - converter: \{{field[:converter].id}}, - key: \{{field[:key].id.stringify}}, - db_default: \{{field[:db_default]}}, - }, - \{% end %} - } - - def self.fields - FIELDS - end - - # Return a `Hash` of fields with their actual values. - # - # ``` - # user = User.new(id: 42) - # post = Post.new(author_id: user.id, content: "foo") - # post.fields # => {:author_id => 42, :content => "foo"} - # ``` - def fields - { - \{% for field in INTERNAL__CORE_FIELDS %} - \{% - val = if field[:converter] - "@#{field[:name].id}.try{ |f| #{field[:converter]}.to_db(f) }" - else - "@#{field[:name].id}" - end - %} - \{{field[:name]}} => \{{val.id}}, - \{% end %} - } of Symbol => Core::Param - end - \{% end %} - - \{% if INTERNAL__CORE_REFERENCES.size > 0 %} - REFERENCES = { - \{% for reference in INTERNAL__CORE_REFERENCES %} - \{{reference[:name]}} => { - "class": \{{reference[:class].id}}, - type: \{{reference[:type].id}}, - key: \{{reference[:key]}}, - foreign_key: \{{reference[:foreign_key]}} - }, - \{% end %} - } - - def self.references - REFERENCES - end - \{% else %} - def self.references - {} of Symbol => Hash(Symbol, Nil) - end - \{% end %} - end - end - end -end diff --git a/src/core/schema/initializer.cr b/src/core/schema/initializer.cr index 239ff3c..4b25156 100644 --- a/src/core/schema/initializer.cr +++ b/src/core/schema/initializer.cr @@ -1,25 +1,18 @@ -module Core - module Schema - private macro define_initializer - property explicitly_initialized : Bool +module Core::Schema + # Would define an `initialize` method for this schema. + # + # Each schema has an `explicitly_initialized : Bool` property, which is set to true when this particular initializer is called. + # + # It would accept named arguments only (e.g. `User.new(42)` is invalid, but `User.new(id: 42)` is). + private macro define_initializer + property explicitly_initialized : Bool - macro finished - def initialize( - {% for field in INTERNAL__CORE_FIELDS %} - @{{field[:name].id}} : {{field[:type].id}} | Nil = {{ field[:default] || nil.id }}, - {% end %} - - \{% for reference in INTERNAL__CORE_REFERENCES %} - @\{{reference[:name].id}} : \{{reference[:class].id}} | Nil = nil, - \{% end %} - - @explicitly_initialized = true, - ) - \{% for reference in INTERNAL__CORE_REFERENCES.select(&.[:key]) %} - @\{{reference[:key].id}} ||= \{{reference[:name].id}}.try &.\{{reference[:foreign_key].id}} - \{% end %} - end - end + def initialize(*, + {% for type in CORE_ATTRIBUTES + CORE_REFERENCES %} + @{{type["name"]}} : {{type["type"]}} | Nil = {{type["default_instance_value"]}}, + {% end %} + ) + @explicitly_initialized = true end end end diff --git a/src/core/schema/mapping.cr b/src/core/schema/mapping.cr deleted file mode 100644 index ae66da7..0000000 --- a/src/core/schema/mapping.cr +++ /dev/null @@ -1,109 +0,0 @@ -require "db/result_set" - -module Core - module Schema - private class ColumnIndexer - property value = 0 - end - - private macro define_db_mapping - macro finished - include ::DB::Mappable - - def self.from_rs(rs : ::DB::ResultSet) - objects = Array(self).new - - rs.each do |rs| - objects << self.new(rs) - end - - return objects - ensure - rs.close - end - - def initialize(rs : ::DB::ResultSet, reference = false, column_indexer : ColumnIndexer = ColumnIndexer.new) - @explicitly_initialized = false - - {% skip_file() if INTERNAL__CORE_FIELDS.empty? %} - - %temp_fields = Hash(Symbol, {{INTERNAL__CORE_FIELDS.map(&.[:type]).join(" | ").id}}).new - - \{% if !INTERNAL__CORE_REFERENCES.empty? %} - %temp_references = Hash(Symbol, \{{INTERNAL__CORE_REFERENCES.map(&.[:type]).join(" | ").id}}).new - \{% end %} - - while column_indexer.value < rs.column_count - column_name = rs.column_name(column_indexer.value) - - case - \{% for field in INTERNAL__CORE_FIELDS %} - # Once a field matched by key with a column, it's value will not be overwritten. - # - # For example, when querying Post joining User, both have "id" column; the first occurence will go to `%temp_fields`, the second one will be skipped. - when column_name == \{{field[:key].id.stringify}} && !%temp_fields.has_key?(\{{field[:name]}}) - \{% if field[:converter] %} - %temp_fields[\{{field[:name]}}] = \{{field[:converter]}}.from_rs(rs) - \{% else %} - %temp_fields[\{{field[:name]}}] = rs.read(\{{field[:type]}}) - \{% end %} - - column_indexer.value += 1 - \{% end %} - else - # Do not allow deep references - if reference - %temp_fields.size > 0 ? break : return nil - end - - \{% for reference in INTERNAL__CORE_REFERENCES.select{ |r| !r[:array] } %} - if !%temp_references.has_key?(\{{reference[:name]}}) && column_name == "_" + \{{reference[:name].id.stringify}} - rs.read - column_indexer.value += 1 - - %temp = \{{reference[:type].id}}.new(rs, \{{reference[:name]}}, column_indexer) - %temp_references[\{{reference[:name]}}] = %temp - end - \{% end %} - - # RFC: Column is not mappable neither for self nor for any reference, skip further columns then. - break - end - end - - %temp_fields.each do |name, value| - case name - \{% for field in INTERNAL__CORE_FIELDS %} - when \{{field[:name]}} - @\{{field[:name].id}} = value.as(\{{field[:type].id}}) || \{{field[:default] || nil.id}} - \{% end %} - else - raise "Unknown field #{name} in %temp_fields" - end - end - - \{% if !INTERNAL__CORE_REFERENCES.empty? %} - %temp_references.each do |name, value| - case name - \{% for reference in INTERNAL__CORE_REFERENCES %} - when \{{reference[:name]}} - \{% if reference[:array] %} - @\{{reference[:name].id}} = [value.as(\{{reference[:type].id}})] - \{% else %} - @\{{reference[:name].id}} = value.as(\{{reference[:type].id}} | Nil) - \{% end %} - - \{% if reference[:key] %} - @\{{reference[:key].id}} ||= \{{reference[:name].id}}.try &.\{{reference[:foreign_key].id}} - \{% end %} - \{% end %} - else - raise "Unknown reference #{name} in %temp_references" - end - end - \{% end %} - end - end - end - end -end diff --git a/src/core/schema/query_enums.cr b/src/core/schema/query_enums.cr new file mode 100644 index 0000000..321aa7b --- /dev/null +++ b/src/core/schema/query_enums.cr @@ -0,0 +1,96 @@ +module Core::Schema + private macro define_query_enums + enum Attribute + {% for type in CORE_ATTRIBUTES %} + {{type["name"].capitalize}} + {% end %} + + def key + case value + {% for type, i in CORE_ATTRIBUTES %} + when {{i}} then {{type["key"]}} + {% end %} + else raise "Bug: unknown value '#{value}'" + end + end + end + + # Forbid direct enumerable references to join (e.g. `type tags : Array(Tag), key: "tag_ids"`) + {% references = CORE_REFERENCES.reject { |r| r["direct"] && r["enumerable"] } %} + + {% if references.size > 0 %} + enum Reference + {% for type in references %} + {{type["name"].capitalize}} + {% end %} + + def direct? + case value + {% for type, i in references %} + when {{i}} then {{type["direct"]}} + {% end %} + else raise "Bug: unknown value '#{value}'" + end + end + + def foreign? + case value + {% for type, i in references %} + when {{i}} then {{type["foreign"]}} + {% end %} + else raise "Bug: unknown value '#{value}'" + end + end + + def table + case value + {% for type, i in references %} + when {{i}} + {{type["true_type"]}}::CORE_TABLE + {% end %} + else + raise "Bug: unknown value '#{value}'" + end + end + + def key + case value + {% for type, i in references %} + when {{i}} + {% if type["direct"] %} + {{type["key"]}} + {% else %} + raise "Foreign references don't have table keys" + {% end %} + {% end %} + else + raise "Bug: unknown value '#{value}'" + end + end + + def foreign_key + case value + {% for type, i in references %} + when {{i}} + if {{type["foreign"]}} + {{type["foreign_key"]}} + else + raise "Direct references don't have foreign table keys" + end + {% end %} + else raise "Bug: unknown value '#{value}'" + end + end + + def primary_key + case value + {% for type, i in references %} + when {{i}} then {{type["true_type"]}}::PRIMARY_KEY + {% end %} + else raise "Bug: unknown value '#{value}'" + end + end + end + {% end %} + end +end diff --git a/src/core/schema/query_shortcuts.cr b/src/core/schema/query_shortcuts.cr new file mode 100644 index 0000000..46ddf54 --- /dev/null +++ b/src/core/schema/query_shortcuts.cr @@ -0,0 +1,40 @@ +module Core::Schema + private macro define_query_shortcuts + def self.query + Core::Query(self).new + end + + {% for method in %w(group_by having insert limit offset set where) %} + # Create new `Core::Query` and call {{method}} on it. + def self.{{method.id}}(*args, **nargs) + query.{{method.id}}(*args, **nargs) + end + {% end %} + + {% for method in %w(update delete all one first last) %} + def self.{{method.id}} + query.{{method.id}} + end + {% end %} + + def self.join(table : String, on : String, *, type _type : Core::Query::JoinType = :inner, as _as : String | Nil = nil) + query.join(table, on, type: _type, as: _as) + end + + def self.join(reference : Reference, *, type _type : Core::Query::JoinType = :inner, **options) + query.join(reference, **options, type: _type) + end + + def self.order_by(value : Attribute | String, order : Core::Query::Order | Nil = nil) + query.order_by(value, order) + end + + def self.returning(*values : Attribute | String | Char) + query.returning(*values) + end + + def self.select(*values : Attribute | String | Char) + query.select(*values) + end + end +end diff --git a/src/core/schema/references.cr b/src/core/schema/references.cr deleted file mode 100644 index 861024c..0000000 --- a/src/core/schema/references.cr +++ /dev/null @@ -1,105 +0,0 @@ -module Core - module Schema - # Define a reference. - # - # Generates an always nilable property, as well as initializers, for example: - # - # ``` - # schema do - # reference :referrer, User - # reference :posts, Array(Post) - # end - # - # # Will expand into: - # - # property referrer : User? - # property posts : Array(Post)? - # - # def initialize(@referrer : User? = nil, @posts : Array(Post)? = nil) - # end - # ``` - # - # If a *key* argument is given, also generates a field property. Basically, the key should copy one from SQL schema. For example: - # - # ``` - # schema do - # primary_key :id - # reference :referrer, User, key: :referrer_id - # reference :creator, User, key: :creator_id - # end - # - # # Will expand in: - # - # property referrer : User? - # property referrer_id : PrimaryKey? - # property creator : User? - # property creator_id : PrimaryKey? - # - # def initialize( - # @id : PrimaryKey? = nil, - # @referrer : User? = nil, - # @creator : User? = nil, - # @referrer_id : PrimaryKey? = nil, - # @creator_id : PrimaryKey? = nil - # ) - # @referrer_id ||= @referrer.try &.id # Smart! - # @creator_id ||= @creator.try &.id - # end - # ``` - # - # A reference's primary key is obtained from the reference *class* itself, or, if given, from *foreign_key*. But remember that *foreign_key* is required for `Query::Instance#join`. - # - # Another example: - # - # ``` - # module Models - # class User - # include Core::Schema - # - # schema "users" do - # primary_key :id - # reference :referrer, User, key: :referrer_id - # reference :referrals, Array(User), foreign_key: :referrer_id - # reference :posts, Array(Post), foreign_key: :author_id - # end - # end - # - # class Post - # include Core::Schema - # - # schema "posts" do - # reference :author, User, key: :author_id - # end - # end - # end - # ``` - macro reference(name, class klass, key = nil, key_type = nil, foreign_key = nil) - {% - _type = klass.is_a?(Generic) ? klass.type_vars.first : klass - is_array = klass.is_a?(Generic) ? klass.name.resolve.name == "Array(T)" : false - %} - - macro finished - \{% - foreign_key = {{foreign_key}}.is_a?(NilLiteral) ? {{_type}}.constant("PRIMARY_KEY")[:name] : {{foreign_key}} - - INTERNAL__CORE_REFERENCES.push({ - name: {{name}}, - "class": {{klass.stringify}}, - type: {{_type}}, - array: {{is_array}}, - key: {{key}}, - foreign_key: foreign_key, - }) - %} - - property {{name.id}} : {{klass.id}} | Nil - - \{% if {{key}} %} - \{% key_type = {{key_type}}.is_a?(NilLiteral) ? {{_type}}.constant("PRIMARY_KEY")[:type] : {{key_type}} %} - field({{key}}, \{{key_type}}, nilable: true) - \{% end %} - end - end - end -end diff --git a/src/core/validation.cr b/src/core/validation.cr deleted file mode 100644 index e8d0b12..0000000 --- a/src/core/validation.cr +++ /dev/null @@ -1,176 +0,0 @@ -module Core - # A module which helps to validate models in a convenient way. - # - # It has to be included into a model **explicitly**. - # - # Implemented inline validations (defined as `:validate` option on field): - # - *size* (`Range | Int32`) - Validate size; - # - *min* (`Comparable`) - Check if field value `>=` than min; - # - *max* (`Comparable`) - Check if field value `<=` than max; - # - *min!* (`Comparable`) - Check if field value `>` than min; - # - *max!* (`Comparable`) - Check if field value `<` than max; - # - *in* (`Enumerable`) - Validate if field value is included in range or array etc.; - # - *regex* (`Regex`) - Validate if field value matches regex; - # - *custom* (`Proc`) - Custom validation, see example below; - # - # ``` - # class User - # include Core::Schema - # include Core::Validation - # - # schema do - # field :name, String, validate: { - # size: (3..32), - # regex: /\w+/, - # custom: ->(name : String) { - # error!(:name, "Some condition not met") unless some_condition?(name) - # }, - # } - # field :age, Int32?, validate: {in: (18..150)} - # end - # - # validate do - # error!(:custom_field, "some error occured") unless some_condition - # end - # end - # ``` - # - # NOTE: A `#nil?` validation will be run at first if the field is defined as non-nilable. - module Validation - macro define_validation - getter errors = Array(Hash(Symbol, String)).new - - # Add an error to `errors`, stopping further validations. - # - # ``` - # field :name, String, validate: ->(n : String) { error!(:name, "invalid") } - # ... - # validate do - # error!(:name, "another error") - # will_not_be_called - # end - # ``` - protected def error!(field, description) - @errors.push({field => description}) - raise Throw.new - end - - # Check if the model is valid. - def valid? - validate - @errors.empty? - end - - # Ensure that the model is valid, otherwise raise `ValidationError`. - def valid! - raise ValidationError.new(self.class, @errors) unless valid? - return self - end - - # Execute validation; should be called manually, prefer `valid?`. - def validate - @errors.clear - - {% for _field in INTERNAL__CORE_FIELDS %} - begin - {{field = _field[:name]}} - - if @{{field.id}}.nil? - if !{{_field[:nilable]}} && !({{_field[:db_default]}} && explicitly_initialized) - error!({{field}}, "must not be nil") - end - else - {% if validations = _field[:options] && _field[:options][:validate] %} - value = @{{field.id}}.not_nil! - - {% if validations[:size] %} - case size = {{validations[:size].id}} - when Int32 - unless value.size == size - error!({{field}}, "must have exact size of #{size}") - end - when Range - unless (size).includes?(value.size) - error!({{field}}, "must have size in range of #{size}") - end - end - {% end %} - - {% if validations[:in] %} - unless ({{validations[:in]}}).includes?(value) - error!({{field}}, "must be included in {{validations[:in].id}}") - end - {% end %} - - {% if validations[:min] %} - unless value >= {{validations[:min]}} - error!({{field}}, "must be greater or equal to {{validations[:min].id}}") - end - {% end %} - - {% if validations[:max] %} - unless value <= {{validations[:max]}} - error!({{field}}, "must be less or equal to {{validations[:max].id}}") - end - {% end %} - - {% if validations[:min!] %} - unless value > {{validations[:min!]}} - error!({{field}}, "must be greater than {{validations[:min!].id}}") - end - {% end %} - - {% if validations[:max!] %} - unless value < {{validations[:max!]}} - error!({{field}}, "must be less than {{validations[:max!].id}}") - end - {% end %} - - {% if validations[:regex] %} - unless {{validations[:regex]}}.match(value) - error!({{field}}, "must match {{validations[:regex].id}}") - end - {% end %} - - {% if validations[:custom] %} - {{validations[:custom].id}}.call(value) - {% end %} - {% end %} - end - rescue ex : Throw - end - {% end %} - end - end - - # Define a custom validations block, which will be run **after** inline validations. - # - # ``` - # class User - # validate do - # error!(:custom, "some condition failed") unless some_condition - # end - # end - # ``` - private macro validate(&block) - def validate - previous_def - begin - {{yield}} - rescue ex : Throw - end - end - end - - private class Throw < Exception - end - - class ValidationError < Exception - getter errors - - def initialize(model, @errors : Array(Hash(Symbol, String))) - super("#{model} validation failed: #{@errors}") - end - end - end -end diff --git a/src/ext/array.cr b/src/ext/array.cr deleted file mode 100644 index 09dfd74..0000000 --- a/src/ext/array.cr +++ /dev/null @@ -1,33 +0,0 @@ -require "db" - -class Array(T) - # Map `#from_rs` to `T`. - def self.from_rs(rs : DB::ResultSet) - T.from_rs(rs) - end - - # Map `Core::Schema.table_name` to `T`. - def self.table_name - T.table_name - end - - # Map `Core::Schema.primary_key` to `T`. - def self.primary_key - T.primary_key - end - - # Map `Core::Schema.reference_key` to `T`. - def self.reference_key(ref) - T.reference_key(ref) - end - - # Map `Core::Schema.reference_foreign_key` to `T`. - def self.reference_foreign_key(ref) - T.reference_foreign_key(ref) - end - - # Map `Core::Schema.reference_class` to `T`. - def self.reference_class(ref) - T.reference_class(ref) - end -end