diff --git a/.travis.yml b/.travis.yml index 0ea8cdd04..084cf4993 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ language: crystal script: -- crystal spec +- crystal spec -Dquiet - bin/ameba - crystal docs services: diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..345440f31 --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +CRYSTAL_BIN ?= $(shell which crystal) + +test: + $(CRYSTAL_BIN) spec -Dquiet \ No newline at end of file diff --git a/bin/install_cli.sh b/bin/install_cli.sh deleted file mode 100644 index 9513db3e9..000000000 --- a/bin/install_cli.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/sh - -$USER_BIN_PATH=$(echo ~/bin) - -echo "Building clear-cli binaries..." -crystal --release src/clear-cli.cr -o $USER_BIN_PATH/clear-cli diff --git a/manual/CHANGELOG.md b/manual/CHANGELOG.md index a568a9eb9..2a117d533 100644 --- a/manual/CHANGELOG.md +++ b/manual/CHANGELOG.md @@ -1,12 +1,56 @@ -# master/HEAD (v0.6) +# v0.6 + +v0.6 should have shipped polymorphic relations, spec rework and improvement in +documentation. That's a lot of work (honestly the biggest improvement since v0) +and since already a lot of stuff have been integrated, I think it's better to +ship now and prepare it for the next release. + +Since few weeks I'm using Clear in a full-time project, so I can see and correct +many bugs. Clear should now be more stable in term of compilation and should not +crash the compiler (which happened in some borderline cases). ## Features - [EXPERIMENTAL] Add `<<` operation on collection which comes from `has_many` and `has_many through:` -- [EXPERIMENTAL] add `unlink` operation on collection which comes from `has_many through:` +- [EXPERIMENTAL] Add `unlink` method on collection which comes from `has_many through:` +- [EXPERIMENTAL] Add possibility to create model from JSON: + + ```crystal + json = JSON.parse(%({"first_name": "John", "last_name": "Doe", "tags": ["customer", "medical"] })) + User.new(json) + ``` + +- Add of `pluck` and `pluck_col` methods to retrieve one or multiple column in a Tuple, + which are super super fast and convenient! +- Add `Clear.with_cli` method to allow to use the CLI in your project. Check the documentation ! - Release of a guide and documentation to use Clear: https://clear.gitbook.io/project/ -- Comments of source code -- SelectQuery now inherits from `Enumerable(Hash(String, Clear::SQL::Any))` +- Additional comments in the source code +- `SelectQuery` now inherits from `Enumerable(Hash(String, Clear::SQL::Any))` +- Add optional block on `Enum` definition. This allow you to define custom methods for the enum: + ```crystal + Clear.enum ClientType, "company", "non_profit", "personnal" do + def pay_vat? + self == Personnal + end + end + ``` +- Add `?` support in `raw` method: + ```crystal + a = 1 + b = 1000 + c = 2 + where{ raw("generate_series(?, ?, ?)", a, b, c) } + ``` + +## Breaking changes +- Migration: use of `table.column` instead of `table.${type}` (remove the method missing method); this was causing headache + in some case where the syntax wasn't exactly followed, as the error output from the compiler was unclear. +- Renaming of `with_serial_pkey` to `primary_key`; refactoring of the macro-code allowing to add other type of keys. + - Now allow `text`, `int` and `bigint` primary key, with the 0.5 `uid`, `serial` and `bigserial` primary keys. +- Renaming of `Clear::Model::InvalidModelError` to `Clear::Model::InvalidError` and `Clear::Model::ReadOnlyError` to + `Clear::Model::ReadOnly` to simplify as those classes are already in the `Clear::Model` namespace +- `Model#set` methods has been transfered to `Model#reset`, and `Model#set` now change the status of the column to + dirty. (see #81) ## Bug fixes - Fix #66, #62 diff --git a/manual/SUMMARY.md b/manual/SUMMARY.md index 644fb81f6..5b1d073c5 100644 --- a/manual/SUMMARY.md +++ b/manual/SUMMARY.md @@ -23,7 +23,7 @@ * [Triggers](model/lifecycle/callbacks.md) * [Batchs operations](model/batchs-operations/README.md) * [Bulk update](model/batchs-operations/bulk-update.md) - * [Bulk insert](model/batchs-operations/bulk-insert.md) + * [Bulk insert & delete](model/batchs-operations/bulk-insert.md) * [Transactions & Save Points](model/transactions-and-save-points/README.md) * [Transaction & Savepoints](model/transactions-and-save-points/transaction.md) * [Connection pool](model/transactions-and-save-points/connection-pool.md) diff --git a/manual/migrations/migration-cli.md b/manual/migrations/migration-cli.md index f921d3fe1..e4f64d440 100644 --- a/manual/migrations/migration-cli.md +++ b/manual/migrations/migration-cli.md @@ -1,2 +1 @@ # Migration CLI - diff --git a/manual/model/batchs-operations/bulk-update.md b/manual/model/batchs-operations/bulk-update.md index e1f428a17..daf74e161 100644 --- a/manual/model/batchs-operations/bulk-update.md +++ b/manual/model/batchs-operations/bulk-update.md @@ -1,2 +1,22 @@ -# Bulk update +## Bulk update +Any simple query can be transformed to `update` query. The new update query will use the `where` clause as parameter +for the update. + +```ruby +User.query.where(name =~ /[A-Z]/ ). + to_update.set(name: Clear::SQL.unsafe("LOWERCASE(name)")).execute +``` + +## Bulk delete + +Same apply for DELETE query. + +```ruby +User.query.where(name !~ /[A-Z]/ ). + to_delete.execute +``` + +{% hint style="warning" %} +Beware: Bulk update and delete do not trigger any model lifecycle hook. Proceed with care. +{% endhint %} \ No newline at end of file diff --git a/manual/model/column-types/converters.md b/manual/model/column-types/converters.md index e0d6d2566..713ae2115 100644 --- a/manual/model/column-types/converters.md +++ b/manual/model/column-types/converters.md @@ -1,2 +1,80 @@ # Converters +Any type from PostgreSQL can be converted using converter objects. +By default, Clear converts already the main type of PostgreSQL. + +However, custom type may not be supported yet. Clear offers you the possibility to add a custom converter. + +## Declare a new converter + +The example below with a converter for a `Color` structure shoudl be straight-forward: + +```ruby +require "./base" + +struct MyApp::Color + property r : UInt8 = 0 + property g : UInt8 = 0 + property b : UInt8 = 0 + property a : UInt8 = 0 + + def to_s + # ... + end + + def self.from_string(x : String) + # ... + end + + def self.from_slice(x : Slice(UInt8)) + # ... + end +end + +class MyApp::ColorConverter + def self.to_column(x) : MyApp::Color? + case x + when Nil + nil + when Slice(UInt8) + MyApp::Color.from_slice(x) + when String + MyApp::Color.from_string(x) + else + raise "Cannot convert from #{x.class} to MyApp::Color" + end + end + + def self.to_db(x : MyApp::Color?) + x.to_s #< css style output, e.g. #12345400 + end +end + +Clear::Model::Converter.add_converter("MyApp::Color", MyApp::ColorConverter) +``` + +Then you can use your mapped type in your model: + +```ruby +class MyApp::MyModel + include Clear::Model + #... + column color : Color #< Automatically get the converter +end +``` + +## `converter` option + +Optionally, you may want to use a converter which is not related to the type itself. To do so, you can pass the +converter name as optional argument in the `column` declaration: + +```ruby +class MyApp::MyModel + include Clear::Model + #... + column s : String, converter: "my_custom_converter" +end +``` + +By convention, converters which map struct and class directly are named using CamelCase, while converters which are not +automatic should be named using the underscore notation. \ No newline at end of file diff --git a/manual/model/column-types/primary-keys.md b/manual/model/column-types/primary-keys.md index 4bc309f50..be655a035 100644 --- a/manual/model/column-types/primary-keys.md +++ b/manual/model/column-types/primary-keys.md @@ -6,9 +6,9 @@ Clear needs your model to define a primary key column. By default, Clear can han As time of writing this manual, compound primary keys are not handled properly. {% endhint %} -## `with_serial_pkey` helper +## `primary_key` helper -Clear offers a built-in `with_serial_pkey` helper which will define your primary key without hassle: +Clear offers a built-in `primary_key` helper which will define your primary key without hassle: ```ruby class Product @@ -16,13 +16,13 @@ class Product self.table = "products" - with_serial_pkey name: "product_id", type: :uuid + primary_key name: "product_id", type: :uuid end ``` -* `name` is the name of your column in your table. Set to `id` by default -* `type` is the type of the column in your table. Set to `bigserial` by default. - * type can be of type `bigserial`, `serial`, `text` and `uuid`. +* `name` is the name of your column in your table. (Default: `id`) +* `type` is the type of the column in your table. Set to (Default: `bigserial`). +* By default, types can be of type `bigserial`, `serial`, `int`, `bigint`, `text` and `uuid`. {% hint style="info" %} In case of `uuid`, Clear will generate a new `uuid` at every new object creation before inserting it into the database. diff --git a/manual/model/lifecycle/validations.md b/manual/model/lifecycle/validations.md index ce4058877..5cd2731a2 100644 --- a/manual/model/lifecycle/validations.md +++ b/manual/model/lifecycle/validations.md @@ -47,13 +47,12 @@ class Article column description : String def validate - ensure_than :description, "must contains at least 100 characters", - &.size.<(100) + ensure_than :description, "must contains at least 100 characters", &.size.<(100) end end ``` -The code above will perform exactly like the previous one, with focus on compact syntax. +The code above will perform exactly like the previous one, while keeping a more compact syntax. ## Error object @@ -67,7 +66,7 @@ a.content = "Lorem ipsum" unless a.valid? a.errors.each do |err| - puts "Error on column: #{err.column} => #{err.reason}" + puts "Error on column: #{err.column} => #{err.reason}" end end ``` diff --git a/manual/model/transactions-and-save-points/transaction.md b/manual/model/transactions-and-save-points/transaction.md index 5ffb51376..05484187e 100644 --- a/manual/model/transactions-and-save-points/transaction.md +++ b/manual/model/transactions-and-save-points/transaction.md @@ -39,7 +39,7 @@ Clear::SQL.transaction do Clear::SQL.rollback puts "This should not print" end - puts "Eventually, I do something else" + puts "This will never reach too." end ``` diff --git a/manual/other-resources/benchmark.md b/manual/other-resources/benchmark.md index f076429f3..b9836c7d0 100644 --- a/manual/other-resources/benchmark.md +++ b/manual/other-resources/benchmark.md @@ -16,15 +16,16 @@ Another good performance improvement would be to connect through [PGBouncer](htt ## Query and fetching benchmark -Here is a simple benchmark comparing the different layers of Clear and how they impact the performance, over a 100k row simple table: - -| Method | Total Time | Speed | -| :--- | :--- | :--- | -| `User.query.each` | \( 83.03ms\) \(± 3.87%\) | 2.28× slower | -| `User.query.each_with_cursor` | \( 121.0ms\) \(± 1.25%\) | 3.32× slower | -| `User.query.each(fetch_columns: true)` | \( 97.12ms\) \(± 4.07%\) | 2.67× slower | -| `User.query.each_with_cursor(fetch_columns: true)` | \(132.52ms\) \(± 2.39%\) | 3.64× slower | -| `User.query.fetch` | \( 36.42ms\) \(± 5.05%\) | fastest | +Here is a simple benchmark comparing the different layers of Clear and how they impact the performance, over a 100k row very simple table: + +``` +With Model: With attributes and cursor 7.4 (135.09ms) (± 6.44%) 116409530 B/op 5.64× slower + With Model: With cursor 8.61 (116.08ms) (± 2.82%) 97209247 B/op 4.84× slower + With Model: With attributes 13.78 ( 72.59ms) (± 3.61%) 83101520 B/op 3.03× slower + With Model: Simple load 100k 16.41 ( 60.94ms) (± 3.22%) 63901872 B/op 2.54× slower + Hash from SQL only 30.21 ( 33.1ms) (± 5.18%) 22354496 B/op 1.38× slower + Using: Model::Collection#pluck 41.74 ( 23.96ms) (± 8.35%) 25337128 B/op fastest +``` ## Against the competition diff --git a/manual/querying/the-collection-object/joins.md b/manual/querying/the-collection-object/joins.md index a3ca7e4df..883467e19 100644 --- a/manual/querying/the-collection-object/joins.md +++ b/manual/querying/the-collection-object/joins.md @@ -22,7 +22,7 @@ Joins are built using `inner_join`, `left_join`, `right_join`, `cross_join` or s ```ruby # Retrieve users with supervisors -User.query.left_joins("users as u2"){ users.supervisor_id = u2.id } +User.query.left_joins("users as u2"){ users.supervisor_id == u2.id } ``` Additionally, optional parameter `lateral` can be set to true to create a LATERAL JOIN. diff --git a/manual/querying/the-collection-object/window-and-cte.md b/manual/querying/the-collection-object/window-and-cte.md index 6d97e22f4..1d80aed03 100644 --- a/manual/querying/the-collection-object/window-and-cte.md +++ b/manual/querying/the-collection-object/window-and-cte.md @@ -10,7 +10,7 @@ In this case, using joins onto a generated series of day is the way to go. CTE m ```ruby dates_in_september = Clear::SQL.select({ - day_start: "generate_series(date '2018-09-01', date '2018-09-30', '1 day'::interval)", + day_start: "generate_series(date '2018-09-01', date '2018-09-30', '1 day'::interval)", day_end: "generate_series(date '2018-09-01', date '2018-09-30', '1 day'::interval) + '1 day'::interval"; }) @@ -18,14 +18,14 @@ Clear::SQL.select({ count: "COUNT(users.*)", day: "dates.day_start" }) -.with_cte(dates: dates_in_septembers) -.from("dates") -.left_joins(User.table){ (users.created_at >= day_start) & (users.created_at < day_end) } -.group_by("dates.day_start") -.order_by("dates.day_start") -.fetch do |hash| - puts "users created the #{hash["day"]}: #{hash["count"]}" -end + .with_cte(dates: dates_in_septembers) + .from("dates") + .left_joins(User.table){ (users.created_at >= day_start) & (users.created_at < day_end) } + .group_by("dates.day_start") + .order_by("dates.day_start") + .fetch do |hash| + puts "users created the #{hash["day"]}: #{hash["count"]}" + end ``` {% hint style="info" %} diff --git a/sample/benchmark/model.cr b/sample/benchmark/model.cr index 7deddda66..780e1bd11 100644 --- a/sample/benchmark/model.cr +++ b/sample/benchmark/model.cr @@ -33,9 +33,10 @@ end puts "Starting benchmarking, total to fetch =" + " #{BenchmarkModel.query.count} records" Benchmark.ips(warmup: 2, calculation: 5) do |x| - x.report("Simple load 100k") { BenchmarkModel.query.limit(100_000).to_a } - x.report("With cursor") { a = [] of BenchmarkModel; BenchmarkModel.query.limit(100_000).each_with_cursor { |o| a << o } } - x.report("With attributes") { BenchmarkModel.query.limit(100_000).to_a(fetch_columns: true) } - x.report("With attributes and cursor") { a = [] of BenchmarkModel; BenchmarkModel.query.limit(100_000).each_with_cursor(fetch_columns: true) { |h| a << h } } - x.report("SQL only") { a = [] of Hash(String, ::Clear::SQL::Any); BenchmarkModel.query.limit(100_000).fetch { |h| a << h } } + x.report("With Model: Simple load 100k") { BenchmarkModel.query.limit(100_000).to_a } + x.report("With Model: With cursor") { a = [] of BenchmarkModel; BenchmarkModel.query.limit(100_000).each_with_cursor { |o| a << o } } + x.report("With Model: With attributes") { BenchmarkModel.query.limit(100_000).to_a(fetch_columns: true) } + x.report("With Model: With attributes and cursor") { a = [] of BenchmarkModel; BenchmarkModel.query.limit(100_000).each_with_cursor(fetch_columns: true) { |h| a << h } } + x.report("Using: Pluck") { BenchmarkModel.query.limit(100_000).pluck("y") } + x.report("Hash from SQL only") { a = [] of Hash(String, ::Clear::SQL::Any); BenchmarkModel.query.limit(100_000).fetch { |h| a << h } } end diff --git a/sample/cli/cli.cr b/sample/cli/cli.cr index 098f4d278..de341f325 100644 --- a/sample/cli/cli.cr +++ b/sample/cli/cli.cr @@ -34,4 +34,6 @@ class ApplyChange2 end end -Clear::CLI.run +Clear.with_cli do + puts "Usage: crystal sample/cli/cli.cr -- clear [args]" +end \ No newline at end of file diff --git a/shard.lock b/shard.lock index bb6bb1766..3a2acd342 100644 --- a/shard.lock +++ b/shard.lock @@ -6,7 +6,7 @@ shards: ameba: github: veelenga/ameba - version: 0.9.0 + commit: d2b36047ef910defda7fb9acb6816a6fa6b6e917 db: github: crystal-lang/crystal-db diff --git a/shard.yml b/shard.yml index 866eb648d..be6291619 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: clear -version: 0.5 +version: 0.6 authors: - Yacine Petitprez @@ -27,8 +27,8 @@ development_dependencies: # github: anykeyh/crystal-coverage ameba: github: veelenga/ameba - version: "~> 0.9" + branch: master -crystal: 0.27.0 +crystal: 0.27.2 license: MIT diff --git a/spec/extensions/bcrypt_spec.cr b/spec/extensions/bcrypt_spec.cr index 7dc21947f..8a2b7d18e 100644 --- a/spec/extensions/bcrypt_spec.cr +++ b/spec/extensions/bcrypt_spec.cr @@ -9,7 +9,7 @@ module BCryptSpec def change(dir) create_table(:bcrypt_users, id: :uuid) do |t| - t.string :encrypted_password + t.column "encrypted_password", "string" end end end @@ -17,7 +17,7 @@ module BCryptSpec class User include Clear::Model - with_serial_pkey type: :uuid + primary_key type: :uuid self.table = "bcrypt_users" diff --git a/spec/extensions/enum_spec.cr b/spec/extensions/enum_spec.cr index 6d63f7667..20bcdccfb 100644 --- a/spec/extensions/enum_spec.cr +++ b/spec/extensions/enum_spec.cr @@ -1,13 +1,17 @@ require "../spec_helper" -module EnumSpec -end -Clear.enum ::EnumSpec::GenderType, "male", "female", "two_part" module EnumSpec extend self + Clear.enum GenderType, "male", "female", "two_part" + Clear.enum ClientType, "company", "non_profit", "personnal" do + def pay_vat? + self == Personnal + end + end + class EnumMigration18462 include Clear::Migration @@ -16,8 +20,8 @@ module EnumSpec create_enum :other_enum, ["a", "b", "c"] create_table(:enum_users) do |t| - t.string :name - t.gender_type :gender + t.column :name, :string + t.column :gender, :gender_type t.timestamps end @@ -37,7 +41,13 @@ module EnumSpec EnumMigration18462.new.apply(Clear::Migration::Direction::UP) end - describe "Clear::Migration::CreateEnum" do + describe "Clear.enum" do + it "can call custom member methods" do + ClientType::Personnal.pay_vat?.should eq true + ClientType::Company.pay_vat?.should eq false + ClientType::NonProfit.pay_vat?.should eq false + end + it "Can create and use enum" do temporary do reinit! diff --git a/spec/extensions/full_text_searchable_spec.cr b/spec/extensions/full_text_searchable_spec.cr index bebc6d4cc..dd42c02ad 100644 --- a/spec/extensions/full_text_searchable_spec.cr +++ b/spec/extensions/full_text_searchable_spec.cr @@ -4,7 +4,7 @@ module FullTextSearchableSpec class Series include Clear::Model - with_serial_pkey + primary_key full_text_searchable "tsv" @@ -19,8 +19,8 @@ module FullTextSearchableSpec def change(dir) create_table "series" do |t| - t.string "title" - t.string "description" + t.column "title", "string" + t.column "description", "string" t.full_text_searchable on: [{"title", 'A'}, {"description", 'C'}], column_name: "tsv" end end diff --git a/spec/extensions/interval_spec.cr b/spec/extensions/interval_spec.cr new file mode 100644 index 000000000..de86e0272 --- /dev/null +++ b/spec/extensions/interval_spec.cr @@ -0,0 +1,55 @@ +require "../spec_helper" + +module IntervalSpec + + class IntervalMigration78392 + include Clear::Migration + + def change(dir) + create_table(:interval_table) do |t| + t.column :i, :interval, null: false + + t.timestamps + end + end + end + + def self.reinit! + reinit_migration_manager + IntervalMigration78392.new.apply(Clear::Migration::Direction::UP) + end + + class IntervalModel + include Clear::Model + + primary_key + + self.table = "interval_table" + + column i : Clear::SQL::Interval + end + + describe Clear::SQL::Interval do + it "Can be saved into database (and converted to pg interval type)" do + temporary do + reinit! + + 3.times do |x| + mth = Random.rand(-1000..1000) + days = Random.rand(-1000..1000) + microseconds = Random.rand(-10000000..10000000) + + IntervalModel.create id: x, i: Clear::SQL::Interval.new(months: mth, days: days, microseconds: microseconds) + + mdl = IntervalModel.find! x + mdl.i.months.should eq mth + mdl.i.days.should eq days + mdl.i.microseconds.should eq microseconds + end + + end + end + end + + +end \ No newline at end of file diff --git a/spec/migration/migration_spec.cr b/spec/migration/migration_spec.cr index 220cd4397..e93790a1c 100644 --- a/spec/migration/migration_spec.cr +++ b/spec/migration/migration_spec.cr @@ -9,10 +9,10 @@ module MigrationSpec def change(dir) create_table(:test) do |t| - t.string :first_name, index: true - t.string :last_name, unique: true + t.column :first_name, "string", index: true + t.column :last_name, "string", unique: true - t.string :tags, array: true, index: "gin" + t.column :tags, "string", array: true, index: "gin" t.index "lower(first_name || ' ' || last_name)", using: :btree diff --git a/spec/model/cache_schema.cr b/spec/model/cache_schema.cr index 7fdaea388..194b1e459 100644 --- a/spec/model/cache_schema.cr +++ b/spec/model/cache_schema.cr @@ -49,11 +49,11 @@ class MigrateSpec10 def change(dir) dir.up do create_table "users" do |t| - t.string "name", unique: true + t.column "name", "string", unique: true end create_table "categories" do |t| - t.string "name", unique: true + t.column "name", "string", unique: true end create_table "user_infos", id: false do |t| @@ -64,9 +64,9 @@ class MigrateSpec10 t.references to: "users", on_delete: "cascade", null: false t.references to: "categories", on_delete: "set null" - t.bool "published", default: false, null: false + t.column "published", "bool", default: false, null: false - t.string "content", null: false + t.column "content", "string", null: false end end end diff --git a/spec/model/event_spec.cr b/spec/model/event_spec.cr index 7cf116d1e..b7ab084a5 100644 --- a/spec/model/event_spec.cr +++ b/spec/model/event_spec.cr @@ -7,8 +7,7 @@ module EventSpec abstract class ModelA include Clear::Model - polymorphic ModelB, - through: "type" + polymorphic through: "type" before(:validate) { ACCUMULATOR << "1" } before(:validate) { ACCUMULATOR << "2" } diff --git a/spec/model/json_spec.cr b/spec/model/json_spec.cr new file mode 100644 index 000000000..23186174f --- /dev/null +++ b/spec/model/json_spec.cr @@ -0,0 +1,31 @@ +require "../spec_helper" +require "./model_spec" + +module ModelSpec + + describe "JSON" do + it "Can load from JSON::Any" do + json = JSON.parse(%<{"id": 1, "first_name": "hello", "last_name": "boss"}>) + + u = User.new.reset(json) + u.id.should eq 1 + u.first_name.should eq "hello" + u.last_name.should eq "boss" + u.changed?.should eq false + + json2 = JSON.parse(%<{"tags": ["a", "b", "c"], "flags": [1, 2, 3]}>) + + p = Post.new(json2) + p.tags.should eq ["a", "b", "c"] + p.flags.should eq [1, 2, 3] + + # Manage hash of JSON::Any::Type, convenient for example with Kemal: + json_h = json2.as_h + p = Post.new(json_h) + p.tags.should eq ["a", "b", "c"] + p.flags.should eq [1, 2, 3] + + end + end + +end \ No newline at end of file diff --git a/spec/model/model_spec.cr b/spec/model/model_spec.cr index 1c5687801..3a0ded5c5 100644 --- a/spec/model/model_spec.cr +++ b/spec/model/model_spec.cr @@ -97,33 +97,33 @@ module ModelSpec def change(dir) create_table "model_categories" do |t| - t.text "name" + t.column "name", "string" t.timestamps end create_table "model_tags", id: :serial do |t| - t.text "name", unique: true, null: false + t.column "name", "string", unique: true, null: false end create_table "model_users" do |t| - t.text "first_name" - t.text "last_name" + t.column "first_name", "string" + t.column "last_name", "string" - t.bool "active", null: true + t.column "active", "bool", null: true - t.add_column "middle_name", type: "varchar(32)" + t.column "middle_name", type: "varchar(32)" - t.jsonb "notification_preferences", index: "gin", default: "'{}'" + t.column "notification_preferences", "jsonb", index: "gin", default: "'{}'" t.timestamps end create_table "model_posts" do |t| - t.string "title", index: true + t.column "title", "string", index: true - t.string "tags", array: true, index: "gin", default: "ARRAY['post', 'arr 2']" - t.bigint "flags", array: true, index: "gin", default: "'{}'::bigint[]" + t.column "tags", "string", array: true, index: "gin", default: "ARRAY['post', 'arr 2']" + t.column "flags", "bigint", array: true, index: "gin", default: "'{}'::bigint[]" t.references to: "model_users", name: "user_id", on_delete: "cascade" t.references to: "model_categories", name: "category_id", null: true, on_delete: "set null" @@ -139,7 +139,7 @@ module ModelSpec create_table "model_user_infos" do |t| t.references to: "model_users", name: "user_id", on_delete: "cascade", null: true - t.int64 "registration_number", index: true + t.column "registration_number", "int64", index: true t.timestamps end @@ -173,6 +173,20 @@ module ModelSpec end end + it "can pluck" do + temporary do + reinit + User.create!(id: 1, first_name: "John", middle_name: "William") + User.create!(id: 2, first_name: "Hans", middle_name: "Zimmer") + + User.query.pluck("first_name", "middle_name").should eq [{"John", "William"}, {"Hans", "Zimmer"}] + User.query.limit(1).pluck_col("first_name").should eq(["John"]) + User.query.limit(1).pluck_col("first_name", String).should eq(["John"]) + User.query.order_by("id").pluck_col("CASE WHEN id % 2 = 0 THEN id ELSE NULL END AS id").should eq([2_i64, nil]) + User.query.pluck("first_name": String, "UPPER(middle_name)": String).should eq [{"John", "WILLIAM"}, {"Hans", "ZIMMER"}] + end + end + it "can detect persistence" do temporary do reinit @@ -330,6 +344,39 @@ module ModelSpec end end + it "can use set to setup multiple fields at once" do + temporary do + reinit + + # Set from tuple + puts "FROM TUPLE" + u = User.new + u.set first_name: "hello", last_name: "world" + u.save! + u.persisted?.should be_true + u.first_name.should eq "hello" + u.changed?.should be_false + + # Set from hash + puts "FROM HASH" + u = User.new + u.set({"first_name" => "hello", "last_name" => "world"}) + u.save! + u.persisted?.should be_true + u.first_name.should eq "hello" + u.changed?.should be_false + + # Set from json + puts "FROM JSON" + u = User.new + u.set(JSON.parse(%<{"first_name": "hello", "last_name": "world"}>)) + u.save! + u.persisted?.should be_true + u.first_name.should eq "hello" + u.changed?.should be_false + end + end + it "can load models" do temporary do reinit diff --git a/spec/model/multiple_connections_spec.cr b/spec/model/multiple_connections_spec.cr index ce0583013..8b6a570f9 100644 --- a/spec/model/multiple_connections_spec.cr +++ b/spec/model/multiple_connections_spec.cr @@ -25,7 +25,7 @@ module MultipleConnectionsSpec def change(dir) create_table "models_posts_two" do |t| - t.string "title", index: true + t.column "title", "string", index: true end end end diff --git a/spec/model/nested_query_spec.cr b/spec/model/nested_query_spec.cr index 80b82c3d9..d4ff95bd8 100644 --- a/spec/model/nested_query_spec.cr +++ b/spec/model/nested_query_spec.cr @@ -6,17 +6,17 @@ module NestedQuerySpec def change(dir) create_table "tags" do |t| - t.bigint "taggable_id", index: true - t.string "name" + t.column "taggable_id", "bigint", index: true + t.column "name", "string" end create_table "videos" do |t| - t.string "name" + t.column "name", "string" end create_table "releases" do |t| - t.bigint "video_id", index: true - t.string "name" + t.column "video_id", "bigint", index: true + t.column "name", "string" end <<-SQL @@ -37,9 +37,8 @@ module NestedQuerySpec self.table = "tags" - with_serial_pkey + primary_key - column id : Int64, primary: true column name : String column taggable_id : Int64 @@ -51,9 +50,8 @@ module NestedQuerySpec self.table = "videos" - with_serial_pkey + primary_key - column id : Int64, primary: true column name : String has_many tags : Tag, foreign_key: "taggable_id" @@ -64,7 +62,7 @@ module NestedQuerySpec self.table = "releases" - with_serial_pkey + primary_key column id : Int64, primary: true column video_id : Int64 diff --git a/spec/model/polymorphism_spec.cr b/spec/model/polymorphism_spec.cr index 855ffea50..78993d545 100644 --- a/spec/model/polymorphism_spec.cr +++ b/spec/model/polymorphism_spec.cr @@ -1,16 +1,15 @@ require "../spec_helper" module PolymorphismSpec + # MODELS POLYMORPHISM abstract class AbstractClass include Clear::Model self.table = "polymorphs" - polymorphic ConcreteClass1, - ConcreteClass2, - through: "type" + polymorphic through: "type" - column common_value : Int32? + column common_value : Int32 abstract def print_value : String end @@ -41,10 +40,10 @@ module PolymorphismSpec def change(dir) create_table "polymorphs" do |t| - t.text "type", index: true, null: false - t.text "string_value" - t.integer "integer_value" - t.integer "common_value" + t.column "type", "text", index: true, null: false + t.column "string_value", "text" + t.column "integer_value", "integer" + t.column "common_value", "integer", null: false end end end @@ -66,7 +65,7 @@ module PolymorphismSpec temporary do reinit - c = ConcreteClass1.new + c = ConcreteClass1.new({common_value: 1}) c.integer_value = 1 c.save! @@ -94,8 +93,8 @@ module PolymorphismSpec temporary do reinit - 5.times { ConcreteClass1.create({integer_value: 1}) } - 10.times { ConcreteClass2.create({string_value: "Yey"}) } + 5.times { ConcreteClass1.create({integer_value: 1, common_value: 1}) } + 10.times { ConcreteClass2.create({string_value: "Yey", common_value: 1}) } c1, c2 = 0, 0 AbstractClass.query.each do |mdl| @@ -110,5 +109,26 @@ module PolymorphismSpec c2.should eq 10 end end + + it "Test different constructors" do + temporary do + reinit + + # I had a bug in production application, which I cannot reproduce with specs. + 5.times { ConcreteClass1.new({integer_value: 1, common_value: 0}).save! } + 10.times { ConcreteClass2.new({"string_value" => "Yey", "common_value" => 1}).save! } + + json = JSON.parse(%<{"string_value": "Yey", "common_value": -1}>) + 10.times { ConcreteClass2.new(json).save! } + + ConcreteClass1.find(1).class.should eq ConcreteClass1 + AbstractClass.find(1).class.should eq ConcreteClass1 + end + end + + it "call validators of both parent and children" do + ConcreteClass1.new.save.should eq false + end + end end diff --git a/spec/model/reflection_spec.cr b/spec/model/reflection_spec.cr index c52c9c4bc..d5dbddbab 100644 --- a/spec/model/reflection_spec.cr +++ b/spec/model/reflection_spec.cr @@ -14,13 +14,13 @@ module ReflectionSpec temporary do first_table = Clear::Reflection::Table.query.first! - expect_raises Clear::Model::ReadOnlyModelError do + expect_raises Clear::Model::ReadOnlyError do first_table.save! end first_table.columns.first!.save.should eq false - expect_raises Clear::Model::ReadOnlyModelError do + expect_raises Clear::Model::ReadOnlyError do first_table.columns.first!.save! end end diff --git a/spec/model/scope_spec.cr b/spec/model/scope_spec.cr index 99934eeb1..827b60c50 100644 --- a/spec/model/scope_spec.cr +++ b/spec/model/scope_spec.cr @@ -24,7 +24,7 @@ module ScopeSpec def change(dir) create_table "scope_models" do |t| - t.integer "value", index: true, null: true + t.column "value", "integer", index: true, null: true end end end diff --git a/spec/model/seed_spec.cr b/spec/model/seed_spec.cr index 854707231..ad5a722db 100644 --- a/spec/model/seed_spec.cr +++ b/spec/model/seed_spec.cr @@ -6,7 +6,7 @@ module SeedSpec self.table = "seed_models" - with_serial_pkey + primary_key column value : String end @@ -16,7 +16,7 @@ module SeedSpec def change(dir) create_table "seed_models" do |t| - t.string "value", index: true, null: false + t.column "value", "string", index: true, null: false end end end diff --git a/spec/model/uuid_spec.cr b/spec/model/uuid_spec.cr index 554ec004e..74b5b32c4 100644 --- a/spec/model/uuid_spec.cr +++ b/spec/model/uuid_spec.cr @@ -7,7 +7,7 @@ module UUIDSpec def change(dir) create_table(:dbobjects, id: :uuid) do |t| - t.string :name, null: false + t.column :name, :string, null: false end create_table(:dbobjects2, id: :uuid) do |t| @@ -23,9 +23,8 @@ module UUIDSpec self.table = "dbobjects" - with_serial_pkey type: :uuid - - has_many db_object : DBObject, foreign_key: "db_object_id" + primary_key type: :uuid + has_many db_objects : DBObject2, foreign_key: "db_object_id" column name : String end @@ -37,7 +36,7 @@ module UUIDSpec belongs_to db_object : DBObject, foreign_key: "db_object_id", key_type: UUID? - with_serial_pkey type: :uuid + primary_key type: :uuid end def self.reinit diff --git a/spec/model/validation_spec.cr b/spec/model/validation_spec.cr index 0c5569706..60a32c759 100644 --- a/spec/model/validation_spec.cr +++ b/spec/model/validation_spec.cr @@ -3,8 +3,19 @@ require "../spec_helper" module ValidationSpec class User include Clear::Model + + primary_key + column user_name : String # Must be present column first_name : String? # No presence + + property? on_presence_working : Bool = false + + def validate + on_presence user_name, first_name do + @on_presence_working = true + end + end end class ValidateNotEmpty @@ -33,6 +44,7 @@ module ValidationSpec /yahoo.[A-Za-z\.]+$/, ].any? { |x| v =~ x } end + end end @@ -50,7 +62,7 @@ module ValidationSpec # In case we select a user from db, byt without user_name in the # selection of column, then the model is still valid for update even # without the presence of user_name. - u = User.new persisted: true + u = User.new({id: 1}, persisted: true) u.valid?.should eq(true) end @@ -70,6 +82,14 @@ module ValidationSpec m.print_errors.should eq("email: must be email, must not be a free email") end + it "can use on_presence helper" do + u = User.new({user_name: "u"}); u.valid? + u.on_presence_working?.should eq false + + u = User.new({user_name: "u", first_name: "f"}); u.valid? + u.on_presence_working?.should eq true + end + it "can validate" do v = ValidateNotEmpty.new v.a = "" diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index bf2c3e703..504aafe8d 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -8,12 +8,12 @@ class ::Crypto::Bcrypt::Password end def initdb - system("echo \"DROP DATABASE IF EXISTS clear_spec;\" | psql -U postgres") - system("echo \"CREATE DATABASE clear_spec;\" | psql -U postgres") + system("echo \"DROP DATABASE IF EXISTS clear_spec;\" | psql -U postgres 2>&1 1>/dev/null") + system("echo \"CREATE DATABASE clear_spec;\" | psql -U postgres 2>&1 1>/dev/null") - system("echo \"DROP DATABASE IF EXISTS clear_secondary_spec;\" | psql -U postgres") - system("echo \"CREATE DATABASE clear_secondary_spec;\" | psql -U postgres") - system("echo \"CREATE TABLE models_post_stats (id serial PRIMARY KEY, post_id INTEGER);\" | psql -U postgres clear_secondary_spec") + system("echo \"DROP DATABASE IF EXISTS clear_secondary_spec;\" | psql -U postgres 2>&1 1>/dev/null") + system("echo \"CREATE DATABASE clear_secondary_spec;\" | psql -U postgres 2>&1 1>/dev/null") + system("echo \"CREATE TABLE models_post_stats (id serial PRIMARY KEY, post_id INTEGER);\" | psql -U postgres clear_secondary_spec 2>&1 1>/dev/null") Clear::SQL.init("postgres://postgres@localhost/clear_spec", connection_pool_size: 5) Clear::SQL.init("secondary", "postgres://postgres@localhost/clear_secondary_spec", connection_pool_size: 5) diff --git a/src/clear/cli.cr b/src/clear/cli.cr index cd4c2e734..aa1dcea43 100644 --- a/src/clear/cli.cr +++ b/src/clear/cli.cr @@ -5,22 +5,35 @@ require "./cli/command" require "./cli/migration" require "./cli/generator" -module Clear::CLI - def self.run(args = nil) - Clear::CLI::Base.run - end +module Clear + module CLI + def self.run + Clear::CLI::Base.run + end + + class Base < Admiral::Command + include Clear::CLI::Command - class Base < Admiral::Command - include Clear::CLI::Command + define_version Clear::VERSION + define_help - define_version Clear::VERSION - define_help + register_sub_command migrate, type: Clear::CLI::Migration + register_sub_command generate, type: Clear::CLI::Generator - register_sub_command migrate, type: Clear::CLI::Migration - register_sub_command generate, type: Clear::CLI::Generator + def run_impl + STDOUT.puts help + end + end + end - def run_impl - STDOUT.puts help + # Check for the CLI. If the CLI is not triggered, yield the block passed as parameter + def self.with_cli(&block) + if ARGV.size > 0 && ARGV[0] == "clear" + ARGV.shift + Clear::CLI.run + else + yield end end + end diff --git a/src/clear/core.cr b/src/clear/core.cr index bb3e56cce..7a4679a26 100644 --- a/src/clear/core.cr +++ b/src/clear/core.cr @@ -1,5 +1,5 @@ module Clear - class_getter logger : Logger = Logger.new(STDOUT) + class_property logger : Logger = Logger.new(STDOUT) end # Require everything except the extensions and the CLI diff --git a/src/clear/expression/expression.cr b/src/clear/expression/expression.cr index d782d9396..59b88b001 100644 --- a/src/clear/expression/expression.cr +++ b/src/clear/expression/expression.cr @@ -98,27 +98,33 @@ class Clear::Expression safe_literal(arg) end + # :nodoc: def self.safe_literal(x : Number) : String x.to_s end + # :nodoc: def self.safe_literal(x : Nil) : String "NULL" end + # :nodoc: def self.safe_literal(x : String) : String {"'", x.gsub('\'', "''"), "'"}.join end + # :nodoc: def self.safe_literal(x : ::Clear::SQL::SelectBuilder) {"(", x.to_sql, ")"} end + # :nodoc: def self.safe_literal(x : ::Clear::Expression::Node) x.resolve end - def self.safe_literal(x : Array(AvailableLiteral)) : Array(String) + # Transform multiple objects into a string which is SQL-Injection safe. + def self.safe_literal(x : Enumerable(AvailableLiteral)) : Enumerable(String) x.map { |item| self.safe_literal(item) } end @@ -128,10 +134,11 @@ class Clear::Expression Clear::Expression::UnsafeSql.new(x) end + # Safe literal of a time return a string representation of time in the format understood by postgresql. # - # Safe literal of a time is the time in the database format - # @params date - # if date is passed, then only the date part of the Time is used: + # If the optional parameter `date` is passed, the time is truncated and only the date is passed: + # + # ## Example # ``` # Clear::Expression[Time.now] # < "2017-04-03 23:04:43.234 +08:00" # Clear::Expression[Time.now, date: true] # < "2017-04-03" @@ -140,18 +147,22 @@ class Clear::Expression {"'", x.to_s(date ? DATABASE_DATE_FORMAT : DATABASE_DATE_TIME_FORMAT), "'"}.join end + # :nodoc: def self.safe_literal(x : Bool) : String (x ? "TRUE" : "FALSE") end + # :nodoc: def self.safe_literal(x : Node) : String x.resolve end + # :nodoc: def self.safe_literal(x : UnsafeSql) : String x.to_s end + # Sanitize an object and return a `String` representation of itself which is proofed against SQL injections. def self.safe_literal(x : _) : String self.safe_literal(x.to_s) end @@ -174,10 +185,6 @@ class Clear::Expression # method. # def self.ensure_node!(any) - # UPDATE: Having precomputed boolean return is - # probably a mistake using the Expression engine - # It is advisable to raise an error in this case, - # because a developer mistake can create a boolean where he doesn't want to. {% raise \ "The expression engine discovered a runtime-evaluable condition.\n" + "It happens when a test is done with values on both sides.\n" + @@ -187,6 +194,7 @@ class Clear::Expression "In this case, please use `raw(\"id IS NULL\")` to allow the expression." %} end + # :nodoc: def self.ensure_node!(node : Node) : Node node end @@ -201,7 +209,11 @@ class Clear::Expression ensure_node!(with expression_engine yield) end - # Not operator + # `NOT` operator + # + # Return an logically reversed version of the contained `Node` + # + # ## Example # # ``` # Clear::Expression.where { not(a == b) }.resolve # >> "WHERE NOT( a = b ) @@ -210,29 +222,48 @@ class Clear::Expression Node::Not.new(x) end + # In case the name of the variable is a reserved word (e.g. `not`, `var`, `raw` ) + # or in case of a complex piece of computation impossible to express with the expression engine + # (e.g. usage of functions) you can use then raw to pass the String. # - # In case the name of the variable is a reserved word (e.g. not or ... raw :P) - # or in case of a complex piece impossible to express with the expression engine - # (mostly usage of functions) - # you can use then raw + # BE AWARE than the String is pasted AS-IS and can lead to SQL injection if not used properly. # # ``` - # where { raw("COUNT(*)") > 5 } + # having { raw("COUNT(*)") > 5 } # SELECT ... FROM ... HAVING COUNT(*) > 5 + # where { raw("func(?, ?) = ?", a, b, c) } # SELECT ... FROM ... WHERE function(a, b) = c # ``` # - # IDEA: raw should accept array splat as second parameters and the "?" keyword # - def raw(x) - Node::Raw.new(x.to_s) + def raw(x : String, *args) + idx = -1 + + clause = x.gsub("?") do |_| + begin + Clear::Expression[args[idx += 1]] + rescue e : IndexError + raise Clear::ErrorMessages.query_building_error(e.message) + end + end + + Node::Raw.new(clause) end - # Flag the content as variable. - # Variables are escaped with double quotes + # Use var to create expression of variable. Variables are columns with or without the namespace and tablename: + # + # It escapes each part of the expression with double-quote as requested by PostgreSQL. + # This is useful to escape SQL keywords or `.` and `"` character in the name of a column. + # + # ```crystal + # var("template1", "users", "name") # "template1"."users"."name" + # var("template1", "users.table2", "name") # "template1"."users.table2"."name" + # var("order") # "order" + # ``` # def var(*parts) _var(parts) end + # :nodoc: private def _var(parts : Tuple, pos = parts.size - 1) if pos == 0 Node::Variable.new(parts[pos].to_s) @@ -243,8 +274,10 @@ class Clear::Expression # Because many postgresql operators are not transcriptable in Crystal lang, # this helpers helps to write the expressions: + # # ```crystal # where { op(jsonb_field, "something", "?") } #<< Return "jsonb_field ? 'something'" + # ``` # def op(a : (Node | AvailableLiteral), b : (Node | AvailableLiteral), op : String) a = Node::Literal.new(a) if a.is_a?(AvailableLiteral) @@ -253,6 +286,8 @@ class Clear::Expression Node::DoubleOperator.new(a, b, op) end + # :nodoc: + # Used internally by the expression engine. macro method_missing(call) {% if call.args.size > 0 %} args = {{call.args}}.map{ |x| Clear::Expression[x] } diff --git a/src/clear/extensions/core_ext.cr b/src/clear/extensions/core_ext.cr index 37362ffa6..179f83b0f 100644 --- a/src/clear/extensions/core_ext.cr +++ b/src/clear/extensions/core_ext.cr @@ -1,5 +1,7 @@ require "base64" +# Extension of some objects outside of Clear ("Monkey Patching") + struct Char def to_json(json) json.string("#{self}") @@ -7,6 +9,7 @@ struct Char end struct PG::Geo::Box + # :nodoc: def to_json(json) json.object do json.field("x1") { json.number x1 } diff --git a/src/clear/extensions/enum/enum.cr b/src/clear/extensions/enum/enum.cr index 57a1e1fc4..bcc9decfd 100644 --- a/src/clear/extensions/enum/enum.cr +++ b/src/clear/extensions/enum/enum.cr @@ -3,6 +3,7 @@ module Clear end # Clear::Enum wrap the enums used in PostgreSQL. + # See `Clear.enum` macro helper. abstract struct Enum include Clear::Expression::Literal @@ -109,15 +110,21 @@ module Clear # u = User.new # u.gender = MyApp::Gender::Male # ``` - macro enum(name, *values) + macro enum(name, *values, &block) struct {{name.id}} < ::Clear::Enum - private AUTHORIZED_VALUES = {} of String => {{name.id}} - {% for i in values %} {{i.camelcase.id}} = {{name.id}}.new("{{i.id}}") - AUTHORIZED_VALUES["{{i.id}}"] = {{i.camelcase.id}} {% end %} + {% begin %} + AUTHORIZED_VALUES = { + {% for i in values %} + "{{i.id}}" => {{i.camelcase.id}}, + {% end %} + } + {% end %} + + # Return the enum with the string passed as parameter. # Throw Clear::IllegalEnumValueError if the string is not found. def self.from_string(str : String) @@ -161,6 +168,8 @@ module Clear Clear::Model::Converter.add_converter("\{{@type}}", ::Clear::Model::Converter::\{{@type}}Converter) end + + {{yield}} end diff --git a/src/clear/extensions/full_text_searchable/migration.cr b/src/clear/extensions/full_text_searchable/migration.cr index 85cbee090..22bfa0df5 100644 --- a/src/clear/extensions/full_text_searchable/migration.cr +++ b/src/clear/extensions/full_text_searchable/migration.cr @@ -100,7 +100,7 @@ module Clear::Migration::FullTextSearchableTableHelpers def full_text_searchable(on : Array(Tuple(String, Char)), column_name = "full_text_vector", catalog = "pg_catalog.english", trigger_name = nil, function_name = nil) - tsvector(column_name, index: "gin") + column(column_name, "tsvector", index: "gin") migration.not_nil!.add_operation(Clear::Migration::FullTextSearchableOperation.new(self.name, on, catalog, trigger_name, function_name, column_name)) diff --git a/src/clear/extensions/full_text_searchable/model.cr b/src/clear/extensions/full_text_searchable/model.cr index 1ef2aa47c..3b41bfa32 100644 --- a/src/clear/extensions/full_text_searchable/model.cr +++ b/src/clear/extensions/full_text_searchable/model.cr @@ -98,6 +98,8 @@ module Clear::Model::FullTextSearchable # Split a chain written by a user # The problem comes from the usage of `'` in languages like French # which can easily break a tsvector query + # + # ameba:disable Metrics/CyclomaticComplexity (Is parser) private def self.split_to_exp(text) last_char : Char? = nil quote_char : Char? = nil diff --git a/src/clear/extensions/interval/interval.cr b/src/clear/extensions/interval/interval.cr new file mode 100644 index 000000000..b290d0c7d --- /dev/null +++ b/src/clear/extensions/interval/interval.cr @@ -0,0 +1,75 @@ +# Represents the "interval" object of PostgreSQL +struct Clear::SQL::Interval + property microseconds : Int64 = 0 + property days : Int32 = 0 + property months : Int32 = 0 + + def initialize( + years = 0, + months = 0, + weeks = 0, + days = 0, + hours = 0, + minutes = 0, + seconds = 0, + milliseconds = 0, + microseconds = 0 + ) + @months = (12 * years + months).to_i32 + @days = days.to_i32 + @microseconds = ( + microseconds + + milliseconds * 1_000 + + seconds * 1_000_000 + + minutes * 60_000_000 + + hours * 3_600_000_000 + ).to_i64 + end + + def to_db + o = [] of String + + (o << @months.to_s << "months") if @months != 0 + (o << @days.to_s << "days") if @days != 0 + (o << @microseconds.to_s << "microseconds") if @microseconds != 0 + + o.join(" ") + end + + def initialize(io : IO) + @microseconds = io.read_bytes(Int64, IO::ByteFormat::BigEndian) + @days = io.read_bytes(Int32, IO::ByteFormat::BigEndian) + @months = io.read_bytes(Int32, IO::ByteFormat::BigEndian) + end + + def self.decode(x : Slice(UInt8)) + io = IO::Memory.new(x, writeable: false) + Clear::SQL::Interval.new(io) + end + + module Converter + def self.to_column(x) : Clear::SQL::Interval? + case x + when Slice # < Here bug of the crystal compiler with Slice(UInt8), do not want to compile + Clear::SQL::Interval.decode(x.as(Slice(UInt8))) + when Clear::SQL::Interval + x + when Nil + nil + else + raise Clear::ErrorMessages.converter_error(x.class, "Interval") + end + end + + def self.to_db(x : Clear::SQL::Interval?) + if (x) + x.to_db + else + nil + end + end + end + + Clear::Model::Converter.add_converter("Clear::SQL::Interval", Clear::SQL::Interval::Converter) + +end diff --git a/src/clear/model/converter/uuid_converter.cr b/src/clear/extensions/uuid/uuid.cr similarity index 53% rename from src/clear/model/converter/uuid_converter.cr rename to src/clear/extensions/uuid/uuid.cr index b3d3b2dd1..5703357e3 100644 --- a/src/clear/model/converter/uuid_converter.cr +++ b/src/clear/extensions/uuid/uuid.cr @@ -1,5 +1,4 @@ -require "./base" - +# Convert from UUID column to Crystal's UUID class Clear::Model::Converter::UUIDConverter def self.to_column(x) : UUID? case x @@ -12,7 +11,7 @@ class Clear::Model::Converter::UUIDConverter when Nil nil else - raise "Cannot convert from #{x.class} to UUID" + raise Clear::ErrorMessages.converter_error(x.class.name, "UUID") end end @@ -22,3 +21,11 @@ class Clear::Model::Converter::UUIDConverter end Clear::Model::Converter.add_converter("UUID", Clear::Model::Converter::UUIDConverter) + +Clear::Model::HasSerialPkey.add_pkey_type "uuid" do + column __name__ : UUID, primary: true, presence: true + + before(:validate) do |m| + m.as(self).__name__ = UUID.random unless m.persisted? + end +end diff --git a/src/clear/migration/manager.cr b/src/clear/migration/manager.cr index c5d0801e2..a6fb9834d 100644 --- a/src/clear/migration/manager.cr +++ b/src/clear/migration/manager.cr @@ -61,23 +61,24 @@ class Clear::Migration::Manager end end + # Compute the wanted version. if the number is negative, try to get + # the version starting by the end. + private def compute_version(version) + # Apply negative version + return version if version >= 0 + + raise no_migration_yet(version) if current_version.nil? + + list_of_migrations.size + version <= 0 ? 0 : + list_of_migrations[version - 1].uid + end + def apply_to(version, direction = :both) ensure_ready list_of_migrations = @migrations.sort { |a, b| a.uid <=> b.uid } - current_version = self.current_version - - # Apply negative version - if version < 0 - raise no_migration_yet(version) if current_version.nil? - - if list_of_migrations.size + version <= 0 - version = 0 - else - version = list_of_migrations[version - 1].uid - end - end + version = compute_version(version) operations = [] of {Int64, Migration::Direction} diff --git a/src/clear/migration/operation/table.cr b/src/clear/migration/operation/table.cr index 03a156e20..e86f47634 100644 --- a/src/clear/migration/operation/table.cr +++ b/src/clear/migration/operation/table.cr @@ -145,13 +145,18 @@ module Clear::Migration end end - # + # DEPRECATED # Method missing is used to generate add_column using the method name as # column type (ActiveRecord's style) macro method_missing(caller) - type = {{caller.name.stringify}} + {% raise "Migration: usage of Table##{caller.name} is deprecated.\n" + + "Tip: use instead `self.column(NAME, \"#{caller.name}\", ...)`" %} + end - type = case type + def column(name, type, default = nil, null = true, primary = false, + index = false, unique = false, array = false ) + + type = case type.to_s when "string" "text" when "int32", "integer" @@ -161,16 +166,11 @@ module Clear::Migration when "datetime" "timestamp without time zone" else - type + type.to_s end - {% raise "STOP" if caller.name == "add_index" %} - - {% if caller.named_args.is_a?(Nop) %} - self.add_column( {{caller.args[0]}}.to_s, type: type ) - {% else %} - self.add_column( {{caller.args[0]}}.to_s, type: type, {{caller.named_args.join(", ").id}} ) - {% end %} + self.add_column(name.to_s, type: type, default: default, null: null, + primary: primary, index: index, unique: unique, array: array) end end @@ -240,11 +240,11 @@ module Clear::Migration case id when true, :bigserial - table.bigserial :id, primary: true, null: false + table.column "id", "bigserial", primary: true, null: false when :serial - table.serial :id, primary: true, null: false + table.column "id", "serial", primary: true, null: false when :uuid - table.uuid :id, primary: true, null: false + table.column "id", "uuid", primary: true, null: false when false else raise "Unknown key type while try to create new table: `#{id}`. Candidates are :bigserial, :serial and :uuid" + diff --git a/src/clear/model/collection.cr b/src/clear/model/collection.cr index 03c6eed15..b5ca2a938 100644 --- a/src/clear/model/collection.cr +++ b/src/clear/model/collection.cr @@ -166,7 +166,7 @@ module Clear::Model # class `MyModel::Collection` which inherits from `CollectionBase(MyModel)` # # Collection are instantiated using `Model.query` method. - abstract class CollectionBase(T) + class CollectionBase(T) include Enumerable(T) include Clear::SQL::SelectBuilder @@ -181,6 +181,10 @@ module Clear::Model @lock : String? @distinct_value : String? + @polymorphic : Bool = false + @polymorphic_key : String? + @polymorphic_scope : Set(String)? + # :nodoc: @cache : Clear::Model::QueryCache @@ -210,10 +214,18 @@ module Clear::Model @before_query_triggers = [] of -> Void, @tags = {} of String => Clear::SQL::Any, @cache = Clear::Model::QueryCache.new, - @cached_result = nil + @cached_result = nil, ) end + def dup + if @polymorphic + super.flag_as_polymorphic!(@polymorphic_key.not_nil!, @polymorphic_scope.not_nil!) + else + super + end + end + # :nodoc: # Setup the connection of this query to be equal to the one of the model class def connection_name @@ -234,6 +246,16 @@ module Clear::Model self end + # :nodoc: + # Used internally to fetch the models if the collection is flagged as polymorphic + def flag_as_polymorphic!(@polymorphic_key, scope : Enumerable(String)) + @polymorphic = true + polymorphic_scope = @polymorphic_scope = Set(String).new + scope.each{ |x| polymorphic_scope.add(x) } + + self + end + # :nodoc: # Clear the current cache def clear_cached_result @@ -279,8 +301,15 @@ module Clear::Model else o = [] of T - fetch(fetch_all: false) do |hash| - o << T.factory.build(hash, persisted: true, fetch_columns: fetch_columns, cache: @cache) + if @polymorphic + fetch(fetch_all: false) do |hash| + type = hash[@polymorphic_key].as(String) + o << Clear::Model::Factory.build(type, hash, persisted: true, fetch_columns: fetch_columns, cache: @cache).as(T) + end + else + fetch(fetch_all: false) do |hash| + o << Clear::Model::Factory.build(T, hash, persisted: true, fetch_columns: fetch_columns, cache: @cache) + end end o.each(&block) @@ -305,8 +334,15 @@ module Clear::Model if cr cr.each(&block) else - self.fetch_with_cursor(count: batch) do |hash| - yield(T.factory.build(hash, persisted: true, fetch_columns: fetch_columns, cache: @cache)) + if @polymorphic + fetch_with_cursor(count: batch) do |hash| + type = hash[@polymorphic_key].as(String) + yield(Clear::Model::Factory.build(type, hash, persisted: true, fetch_columns: fetch_columns, cache: @cache).as(T)) + end + else + fetch_with_cursor(count: batch) do |hash| + yield(Clear::Model::Factory.build(T, hash, persisted: true, fetch_columns: fetch_columns, cache: @cache)) + end end end end @@ -316,7 +352,7 @@ module Clear::Model # the primary key of `my_model` will be setup by default, preventing you # to forget it. def build : T - T.factory.build(@tags, persisted: false) + Clear::Model::Factory.build(T, @tags, persisted: false) end # Build a new collection; if the collection comes from a has_many relation @@ -326,7 +362,7 @@ module Clear::Model # You can pass extra parameters using a named tuple: # `my_model.associations.build({a_column: "value"}) ` def build(x : NamedTuple) : T - T.factory.build(@tags.merge(x.to_h), persisted: false) + Clear::Model::Factory.build(T, @tags.merge(x.to_h), persisted: false) end # Check whether the query return any row. @@ -335,7 +371,7 @@ module Clear::Model return cr.any? if cr - self.clear_select.select("1").limit(1).fetch do |_| + clear_select.select("1").limit(1).fetch do |_| return true end @@ -374,7 +410,7 @@ module Clear::Model # Alias for `Collection#<<` def add(item : T) - super.<<(item) + self << item end # Unlink the model currently referenced through a relation `has_many through` @@ -387,6 +423,11 @@ module Clear::Model unlink_operation = self.unlink_operation raise "Operation not permitted on this collection." unless unlink_operation + + unlink_operation.call(item) + @cached_result.try &.remove(item) + + self end # Create an array from the query. @@ -454,7 +495,7 @@ module Clear::Model tuple.map { |k, v| str_hash[k.to_s] = v } str_hash.merge!(@tags) - r = T.factory.build(str_hash) + r = Clear::Model::Factory.build(T, str_hash) yield(r) r @@ -481,7 +522,7 @@ module Clear::Model order_by(Clear::SQL.escape("#{T.pkey}"), "ASC") unless T.pkey.nil? || order_bys.any? limit(1).fetch do |hash| - return T.factory.build(hash, persisted: true, cache: @cache, fetch_columns: fetch_columns) + return Clear::Model::Factory.build(T, hash, persisted: true, cache: @cache, fetch_columns: fetch_columns) end nil @@ -518,7 +559,7 @@ module Clear::Model clear_order_bys.order_by(new_order) limit(1).fetch do |hash| - return T.factory.build(hash, persisted: true, cache: @cache, fetch_columns: fetch_columns) + return Clear::Model::Factory.build(T, hash, persisted: true, cache: @cache, fetch_columns: fetch_columns) end nil diff --git a/src/clear/model/column.cr b/src/clear/model/column.cr index 7504e4b87..263d67ae1 100644 --- a/src/clear/model/column.cr +++ b/src/clear/model/column.cr @@ -53,15 +53,30 @@ class Clear::Model::Column(T, C) end def reset_convert(x) - reset( C.to_column x ) + reset C.to_column(x) + end + + def set_convert(x) + set C.to_column(x) + end + + def set(x : T?) + old_value = @value + {% if T.nilable? %} + @value = x.as(T) + {% else %} + raise null_column_mapping_error(@name, T) if x.nil? + @value = x.not_nil! + {% end %} + + @old_value = old_value + @changed = true end # Reset the current field. # Restore the `old_value` state to current value. # Reset the flag `changed` to false. def reset(x : T?) - @changed = false - {% if T.nilable? %} @value = x.as(T) {% else %} @@ -69,6 +84,7 @@ class Clear::Model::Column(T, C) @value = x.not_nil! {% end %} + @changed = false @old_value = @value end diff --git a/src/clear/model/converter/array_converter.cr b/src/clear/model/converters/array_converter.cr similarity index 84% rename from src/clear/model/converter/array_converter.cr rename to src/clear/model/converters/array_converter.cr index 7310046d8..461a5d07c 100644 --- a/src/clear/model/converter/array_converter.cr +++ b/src/clear/model/converters/array_converter.cr @@ -26,6 +26,8 @@ module Clear::Model::Converter::ArrayConverter{{exp.id}} return nil when ::{{exp.id}} return [x] + when Array(::{{exp.id}}) + return x when Array(::PG::{{exp.id}}Array) return x.map do |i| case i @@ -35,12 +37,14 @@ module Clear::Model::Converter::ArrayConverter{{exp.id}} nil end end.compact - else - if arr = ::JSON.parse(x.to_s).as_a? - return arr.map{ |x| x.as_{{k.id}} } + when ::JSON::Any + if arr = x.as_a? + return arr.map(&.as_{{k.id}}) + else + raise "Cannot convert from #{x.class} to Array({{exp.id}}) [1]" end - - return nil + else + raise "Cannot convert from #{x.class} to Array({{exp.id}}) [2]" end end diff --git a/src/clear/model/converter/base.cr b/src/clear/model/converters/base.cr similarity index 100% rename from src/clear/model/converter/base.cr rename to src/clear/model/converters/base.cr diff --git a/src/clear/model/converter/bool_converter.cr b/src/clear/model/converters/bool_converter.cr similarity index 85% rename from src/clear/model/converter/bool_converter.cr rename to src/clear/model/converters/bool_converter.cr index def887854..a9b58ccd5 100644 --- a/src/clear/model/converter/bool_converter.cr +++ b/src/clear/model/converters/bool_converter.cr @@ -5,8 +5,9 @@ require "./base" # value is used: # # falsey's values are: -# false, null, 0, "0", "" (empty string), "false", "f" -# Anything else is considered true +# `false`, `nil`, `0`, `"0"`, `""` (empty string), `"false"`, `"f"` +# +# Anything else is considered `true` module Clear::Model::Converter::BoolConverter def self.to_column(x) : Bool? case x diff --git a/src/clear/model/converter/json_any_converter.cr b/src/clear/model/converters/json_any_converter.cr similarity index 100% rename from src/clear/model/converter/json_any_converter.cr rename to src/clear/model/converters/json_any_converter.cr diff --git a/src/clear/model/converter/number_converters.cr b/src/clear/model/converters/number_converters.cr similarity index 100% rename from src/clear/model/converter/number_converters.cr rename to src/clear/model/converters/number_converters.cr diff --git a/src/clear/model/converter/string_converter.cr b/src/clear/model/converters/string_converter.cr similarity index 100% rename from src/clear/model/converter/string_converter.cr rename to src/clear/model/converters/string_converter.cr diff --git a/src/clear/model/converter/time_converter.cr b/src/clear/model/converters/time_converter.cr similarity index 100% rename from src/clear/model/converter/time_converter.cr rename to src/clear/model/converters/time_converter.cr diff --git a/src/clear/model/errors.cr b/src/clear/model/errors.cr index 79e7e9117..b9a93142e 100644 --- a/src/clear/model/errors.cr +++ b/src/clear/model/errors.cr @@ -1,7 +1,19 @@ module Clear::Model class Error < Exception; end - class InvalidModelError < Error; end + class InvalidError < Error + getter model : Clear::Model - class ReadOnlyModelError < Error; end + def initialize(@model : Clear::Model) + super("The model `#{@model.class}` is invalid:\n#{model.print_errors}") + end + end + + class ReadOnlyError < Error + getter model : Clear::Model + + def initialize(@model : Clear::Model) + super("The model `#{@model.class}` is read-only") + end + end end diff --git a/src/clear/model/factories/base.cr b/src/clear/model/factories/base.cr new file mode 100644 index 000000000..bd3316239 --- /dev/null +++ b/src/clear/model/factories/base.cr @@ -0,0 +1,8 @@ +module Clear::Model::Factory + module Base + abstract def build(h : Hash(String, ::Clear::SQL::Any), + cache : Clear::Model::QueryCache? = nil, + persisted : Bool = false, + fetch_columns : Bool = false) : Clear::Model + end +end \ No newline at end of file diff --git a/src/clear/model/factories/polymorphic_factory.cr b/src/clear/model/factories/polymorphic_factory.cr new file mode 100644 index 000000000..ed777d058 --- /dev/null +++ b/src/clear/model/factories/polymorphic_factory.cr @@ -0,0 +1,37 @@ +require "./base" + +module Clear::Model::Factory + class PolymorphicFactory(T) + include Base + property type_field : String = "" + property self_class : String = "" + + def initialize(@type_field, @self_class) + end + + def build(h : Hash(String, ::Clear::SQL::Any), + cache : Clear::Model::QueryCache? = nil, + persisted : Bool = false, + fetch_columns : Bool = false) : Clear::Model + + v = h[@type_field] + + case v + when String + if v == T.name + {% if T.abstract? %} + raise "Cannot instantiate #{@type_field} because it is abstract class" + {% else %} + T.new(v, h, cache, persisted, fetch_columns).as(Clear::Model) + {% end %} + else + Clear::Model::Factory.build(v, h, cache, persisted, fetch_columns).as(Clear::Model) + end + when Nil + raise Clear::ErrorMessages.polymorphic_nil(@type_field) + else + raise Clear::ErrorMessages.polymorphic_nil(@type_field) + end + end + end +end \ No newline at end of file diff --git a/src/clear/model/factories/simple_factory.cr b/src/clear/model/factories/simple_factory.cr new file mode 100644 index 000000000..04a3a6061 --- /dev/null +++ b/src/clear/model/factories/simple_factory.cr @@ -0,0 +1,14 @@ +require "./base" + +module Clear::Model::Factory + class SimpleFactory(T) + include Base + + def build(h : Hash(String, ::Clear::SQL::Any), + cache : Clear::Model::QueryCache? = nil, + persisted : Bool = false, + fetch_columns : Bool = false) : Clear::Model + T.new(h, cache, persisted, fetch_columns).as(Clear::Model) + end + end +end \ No newline at end of file diff --git a/src/clear/model/factory.cr b/src/clear/model/factory.cr new file mode 100644 index 000000000..746ea3e53 --- /dev/null +++ b/src/clear/model/factory.cr @@ -0,0 +1,29 @@ +require "./factories/**" + +module Clear::Model::Factory + FACTORIES = {} of String => Clear::Model::Factory::Base #Used during compilation time + + macro add(type, factory) + {% Clear::Model::Factory::FACTORIES[type] = factory %} + end + + def self.build(type : String, + h : Hash, + cache : Clear::Model::QueryCache? = nil, + persisted = false, + fetch_columns = false) : Clear::Model + + factory = FACTORIES[type].as(Base) + + factory.build(h, cache, persisted, fetch_columns) + end + + def self.build(type : T.class, + h : Hash, + cache : Clear::Model::QueryCache? = nil, + persisted = false, + fetch_columns = false) : T forall T + + self.build(T.name, h, cache, persisted, fetch_columns).as(T) + end +end diff --git a/src/clear/model/model.cr b/src/clear/model/model.cr index e2310af0b..68df2dc66 100644 --- a/src/clear/model/model.cr +++ b/src/clear/model/model.cr @@ -2,8 +2,9 @@ require "../sql" require "./collection" require "./column" require "./modules/**" -require "./converter/**" +require "./converters/**" require "./validation/**" +require "./factory" module Clear::Model include Clear::ErrorMessages @@ -18,7 +19,7 @@ module Clear::Model include Clear::Model::HasScope include Clear::Model::ClassMethods include Clear::Model::HasJson - include Clear::Model::IsPolymorphic + include Clear::Model::HasFactory include Clear::Model::Initializer getter cache : Clear::Model::QueryCache? @@ -46,16 +47,22 @@ module Clear::Model getter cache : Clear::Model::QueryCache? - def initialize(@persisted = false) + def initialize + @persisted = false end - def initialize(h : Hash(String, ::Clear::SQL::Any ), @cache : Clear::Model::QueryCache? = nil, @persisted = false, fetch_columns = false ) + def initialize(h : Hash(String, _), @cache : Clear::Model::QueryCache? = nil, @persisted = false, fetch_columns = false ) @attributes.merge!(h) if fetch_columns - set(h) + + reset(h) + end + + def initialize(json : ::JSON::Any, @cache : Clear::Model::QueryCache? = nil, @persisted = false ) + reset(json.as_h) end def initialize(t : NamedTuple, @persisted = false) - set(t) + reset(t) end # :nodoc: diff --git a/src/clear/model/modules/class_methods.cr b/src/clear/model/modules/class_methods.cr index aa2c88dda..6a8fc4db4 100644 --- a/src/clear/model/modules/class_methods.cr +++ b/src/clear/model/modules/class_methods.cr @@ -3,16 +3,16 @@ module Clear::Model::ClassMethods macro included # When included into final Model macro inherited #Polymorphism macro finished - __generate_relations - __generate_columns - __init_default_factory + __generate_relations__ + __generate_columns__ + __register_factory__ end end macro finished - __generate_relations - __generate_columns - __init_default_factory + __generate_relations__ + __generate_columns__ + __register_factory__ end # Return the table name setup for this model. diff --git a/src/clear/model/modules/has_columns.cr b/src/clear/model/modules/has_columns.cr index b1c56e6a9..fcd07d039 100644 --- a/src/clear/model/modules/has_columns.cr +++ b/src/clear/model/modules/has_columns.cr @@ -19,19 +19,51 @@ module Clear::Model::HasColumns end end - def set( h : Hash(Symbol, _) ) + # Reset one or multiple columns; Reseting set the current value of the column + # to the given value, while the `changed?` flag remains false. + # If you call save on a persisted model, the reset columns won't be + # commited in the UPDATE query. + def reset( **t : **T ) forall T + # Dev note: + # --------- + # The current implementation of reset is overriden on finalize (see below). + # This method is a placeholder to ensure that we can call `super` + # in case of inherited (polymorphic) models + end + + # See `reset(**t : **T)` + def reset( h : Hash(String, _) ) end - # Set the colums value of your model from Hash. - # The values are then converted using database converter helper. + # See `reset(**t : **T)` + def reset( h : Hash(Symbol, _) ) + end + + + # Set one or multiple columns to a specific value + # This two are equivalents: # # ``` - # model.set({"id" => 1}) - # model.id # 1 + # model.set(a: 1) + # model.a = 1 # ``` - def set( h : Hash(String, ::Clear::SQL::Any) ) + def set( ** t : **T ) forall T + # Dev note: + # --------- + # The current implementation of set is overriden on finalize (see below). + # This method is a placeholder to ensure that we can call `super` + # in case of inherited (polymorphic) models + end + + # See `set(**t : **T)` + def set( h : Hash(String, _) ) + end + + # See `set(**t : **T)` + def set( h : Hash(Symbol, _) ) end + # Access to direct SQL attributes given by the request used to build the model. # Access is read only and updating the model columns will not apply change to theses columns. # @@ -146,11 +178,16 @@ module Clear::Model::HasColumns # :nodoc: # Used internally to gather the columns - macro __generate_columns + macro __generate_columns__ {% for name, settings in COLUMNS %} {% type = settings[:type] %} {% has_db_default = !settings[:presence] %} {% converter = Clear::Model::Converter::CONVERTERS[settings[:converter]] %} + {% if converter == nil %} + {% raise "No converter found for `#{settings[:converter].id}`.\n"+ + "The type is probably not supported natively by Clear.\n"+ + "Please refer to the manual to create a custom converter." %} + {% end %} @{{name}}_column : Clear::Model::Column({{type}}, {{converter}}) = Clear::Model::Column({{type}}, {{converter}}).new("{{name}}", has_db_default: {{has_db_default}} ) @@ -188,8 +225,10 @@ module Clear::Model::HasColumns {% end %} {% end %} + # reset flavors + def reset( **t : **T ) forall T + super - def set( **t : **T ) forall T \{% for name, typ in T %} \{% if !@type.has_method?("#{name}=") %} \{% raise "No method #{@type}##{name}= while trying to set value of #{name}" %} @@ -201,6 +240,58 @@ module Clear::Model::HasColumns self.\{{name}} = t[:\{{name}}] \{% end %} \{% end %} + + self + end + + def reset( t : NamedTuple ) + reset(**t) + end + + # Set the columns from hash + def reset( h : Hash(Symbol, _) ) + super + + \{% for name, settings in COLUMNS %} + v = h.fetch(:\{{settings[:column_name]}}){ Column::UNKNOWN } + @\{{name}}_column.reset_convert(v) unless v.is_a?(Column::UnknownClass) + \{% end %} + + self + end + + # Set the model fields from hash + def reset( h : Hash(String, _) ) + super + + \{% for name, settings in COLUMNS %} + v = h.fetch(\{{settings[:column_name]}}){ Column::UNKNOWN } + @\{{name}}_column.reset_convert(v) unless v.is_a?(Column::UnknownClass) + \{% end %} + + self + end + + def reset( from_json : JSON::Any ) + reset(from_json.as_h) + end + + def set( **t : **T ) forall T + super + + \{% for name, typ in T %} + \{% if !@type.has_method?("#{name}=") %} + \{% raise "No method #{@type}##{name}= while trying to set value of #{name}" %} + \{% end %} + + \{% if settings = COLUMNS["#{name}".id] %} + @\{{name}}_column.set_convert(t[:\{{name}}]) + \{% else %} + self.\{{name}} = t[:\{{name}}] + \{% end %} + \{% end %} + + self end def set( t : NamedTuple ) @@ -211,12 +302,31 @@ module Clear::Model::HasColumns def set( h : Hash(Symbol, _) ) super - {% for name, settings in COLUMNS %} - v = h.fetch(:{{settings[:column_name]}}){ Column::UNKNOWN } - @{{name}}_column.reset_convert(v) unless v.is_a?(Column::UnknownClass) - {% end %} + \{% for name, settings in COLUMNS %} + v = h.fetch(:\{{settings[:column_name]}}){ Column::UNKNOWN } + @\{{name}}_column.set_convert(v) unless v.is_a?(Column::UnknownClass) + \{% end %} + + self + end + + # Set the model fields from hash + def set( h : Hash(String, _) ) + super + + \{% for name, settings in COLUMNS %} + v = h.fetch(\{{settings[:column_name]}}){ Column::UNKNOWN } + @\{{name}}_column.set_convert(v) unless v.is_a?(Column::UnknownClass) + \{% end %} + + self + end + + def set( from_json : JSON::Any ) + set(from_json.as_h) end + # Generate the hash for update request (like during save) def update_h : Hash(String, ::Clear::SQL::Any) o = super @@ -231,11 +341,20 @@ module Clear::Model::HasColumns o end + # set flavors + + # For each column, ensure than when needed the column has present # information into it. # # This method is called on validation. def validate_fields_presence + # It should have only zero (non-polymorphic) or + # one (polymorphic) ancestor inheriting from Clear::Model + {% for ancestors in @type.ancestors %}{% if ancestors < Clear::Model %} + super + {% end %}{% end %} + {% for name, settings in COLUMNS %} unless persisted? if @{{name}}_column.failed_to_be_present? @@ -282,16 +401,5 @@ module Clear::Model::HasColumns return false end - # Set the model fields from hash - def set( h : Hash(String, ::Clear::SQL::Any) ) - super - - {% for name, settings in COLUMNS %} - if h.has_key?({{settings[:column_name]}}) - @{{name}}_column.reset_convert(h[{{settings[:column_name]}}]) - end - {% end %} - end - end end diff --git a/src/clear/model/modules/has_factory.cr b/src/clear/model/modules/has_factory.cr new file mode 100644 index 000000000..c1f7253b5 --- /dev/null +++ b/src/clear/model/modules/has_factory.cr @@ -0,0 +1,75 @@ +require "../factory" + +module Clear::Model::HasFactory + + macro included # In Clear::Model + macro included # In RealModel + POLYMORPHISM_SETTINGS = {} of Nil => Nil + + class_getter? polymorphic : Bool = false + + # Add linking between classes for the EventManager triggers + macro inherited + \\{% for ancestor in @type.ancestors %} + \\{% if ancestor < Clear::Model %} + Clear::Model::EventManager.add_inheritance(\\{{ancestor}}, \\{{@type}}) + \\{% end %} + \\{% end %} + end + end + end + + # :nodoc: + macro __default_factory__ + Clear::Model::Factory.add({{@type.stringify}}, ::Clear::Model::Factory::SimpleFactory({{@type}}).new) + end + + # :nodoc: + # Define a simple model factory which is litteraly just a + # delegate to the constructor. + macro __register_factory__ + {% unless POLYMORPHISM_SETTINGS[:has_factory] %} + __default_factory__ + {% end %} + end + + # Define a polymorphic factory, if the model is tagged as polymorphic + macro polymorphic(through = "type") + {% POLYMORPHISM_SETTINGS[:has_factory] = true %} + + column {{through.id}} : String + + before(:validate) do |model| + model = model.as(self) + model.{{through.id}} = model.class.name + end + + # Subclasses are refined using a default scope + # to filter by type. + macro inherited + class Collection < Clear::Model::CollectionBase(\{{@type}}); end + + def self.query + Collection.new.from(table).where{ {{through.id}} == self.name } + end + end + + # Base class can be refined too, only if the baseclass is not abstract. + {% unless @type.abstract? %} + def self.query + Collection.new.from(table).where{ {{through.id}} == self.name } + end + {% end %} + + def self.polymorphic? + true + end + + Clear::Model::Factory.add("{{@type}}", Clear::Model::Factory::PolymorphicFactory({{@type}}).new({{through.id.stringify}}, "{{@type}}" ) ) + + macro inherited + Clear::Model::Factory.add("\{{@type}}", Clear::Model::Factory::SimpleFactory(\{{@type}}).new ) + end + end + +end \ No newline at end of file diff --git a/src/clear/model/modules/has_relations.cr b/src/clear/model/modules/has_relations.cr index 8a4a3d03e..7a10d17b3 100644 --- a/src/clear/model/modules/has_relations.cr +++ b/src/clear/model/modules/has_relations.cr @@ -37,7 +37,7 @@ module Clear::Model::HasRelations # has_one owner : User # It assumes the table `users` have a column `passport_id` # end # ``` - macro has_one(name, foreign_key = nil, primary_key = nil, no_cache = false) + macro has_one(name, foreign_key = nil, primary_key = nil, no_cache = false, polymorphic = false, foreign_key_type = nil) {% foreign_key = foreign_key.id if foreign_key.is_a?(SymbolLiteral) || foreign_key.is_a?(StringLiteral) primary_key = primary_key.id if primary_key.is_a?(SymbolLiteral) || primary_key.is_a?(StringLiteral) @@ -69,7 +69,7 @@ module Clear::Model::HasRelations # has_many posts : Post, foreign_key: "author_id" # end # ``` - macro has_many(name, through = nil, foreign_key = nil, own_key = nil, primary_key = nil, no_cache = false) + macro has_many(name, through = nil, foreign_key = nil, own_key = nil, primary_key = nil, no_cache = false, polymorphic = false, foreign_key_type = nil) {% if through != nil @@ -77,6 +77,7 @@ module Clear::Model::HasRelations own_key = own_key.id if own_key.is_a?(SymbolLiteral) || own_key.is_a?(StringLiteral) foreign_key = foreign_key.id if foreign_key.is_a?(SymbolLiteral) || foreign_key.is_a?(StringLiteral) + foreign_key_type = foreign_key_type.id if foreign_key_type.is_a?(SymbolLiteral) || foreign_key_type.is_a?(StringLiteral) RELATIONS[name.var.id] = { relation_type: :has_many_through, @@ -84,12 +85,17 @@ module Clear::Model::HasRelations through: through, own_key: own_key, - foreign_key: foreign_key + + foreign_key: foreign_key, + foreign_key_type: foreign_key_type, + + polymorphic: polymorphic } else foreign_key = foreign_key.id if foreign_key.is_a?(SymbolLiteral) || foreign_key.is_a?(StringLiteral) primary_key = primary_key.id if primary_key.is_a?(SymbolLiteral) || primary_key.is_a?(StringLiteral) + foreign_key_type = foreign_key_type.id if foreign_key_type.is_a?(SymbolLiteral) || foreign_key_type.is_a?(StringLiteral) RELATIONS[name.var.id] = { relation_type: :has_many, @@ -97,7 +103,10 @@ module Clear::Model::HasRelations foreign_key: foreign_key, primary_key: primary_key, - no_cache: no_cache + foreign_key_type: foreign_key_type, + + no_cache: no_cache, + polymorphic: polymorphic } end %} @@ -129,7 +138,7 @@ module Clear::Model::HasRelations # :nodoc: # Generate the relations by calling the macro - macro __generate_relations + macro __generate_relations__ {% begin %} {% for name, settings in RELATIONS %} {% if settings[:relation_type] == :belongs_to %} diff --git a/src/clear/model/modules/has_saving.cr b/src/clear/model/modules/has_saving.cr index 8b2cc440c..a8a1a330c 100644 --- a/src/clear/model/modules/has_saving.cr +++ b/src/clear/model/modules/has_saving.cr @@ -37,8 +37,8 @@ module Clear::Model::HasSaving o = [] of self query.fetch(@@connection) do |hash| - o << factory.build(hash, persisted: true, - fetch_columns: false, cache: nil) + o << Clear::Model::Factory.build(self.name, hash, persisted: true, + fetch_columns: false, cache: nil).as(self) end o.each(&.trigger_after_events(:create)) @@ -111,7 +111,7 @@ module Clear::Model::HasSaving on_conflict.call(query) if on_conflict hash = query.execute(@@connection) - self.set(hash) + self.reset(hash) @persisted = true end end @@ -132,10 +132,9 @@ module Clear::Model::HasSaving # Performs like `save`, but instead of returning `false` if validation failed, # raise `Clear::Model::InvalidModelError` exception def save!(on_conflict : (Clear::SQL::InsertQuery -> )? = nil) - raise Clear::Model::ReadOnlyModelError.new("The model is read-only") if self.class.read_only? + raise Clear::Model::ReadOnlyError.new(self) if self.class.read_only? - raise Clear::Model::InvalidModelError.new( - "Validation of the model failed:\n #{print_errors}") unless save(on_conflict) + raise Clear::Model::InvalidError.new(self) unless save(on_conflict) self end diff --git a/src/clear/model/modules/has_serial_pkey.cr b/src/clear/model/modules/has_serial_pkey.cr index 2a457853d..6be146b04 100644 --- a/src/clear/model/modules/has_serial_pkey.cr +++ b/src/clear/model/modules/has_serial_pkey.cr @@ -1,25 +1,65 @@ require "uuid" module Clear::Model::HasSerialPkey + PKEY_TYPE = {} of Nil => Nil + + @[Deprecated] + macro with_serial_pkey(name = "id", type = :bigserial) + {% puts "[DEPRECATION WARNING] Please use `primary_key` instead. In future version of Clear, `with_serial_pkey` will be removed (declared in `#{@type}`).".id %} + primary_key({{name}}, {{type}}) + end + # Macro used to define serializable primary keys. # Currently support `bigserial`, `serial` and `uuid`. # # For `bigserial` and `serial`, let to PostgreSQL the handling of sequence numbers. # For `uuid`, will generate a new `UUID` number on creation. - macro with_serial_pkey(name = "id", type = :bigserial) - - {% if type == :bigserial %} - column {{name.id}} : Int64, primary: true, presence: false - {% elsif type == :serial %} - column {{name.id}} : Int32, primary: true, presence: false - {% elsif type == :uuid %} - column {{name.id}} : UUID, primary: true, presence: true - - before(:validate) do |m| - m.as(self).{{name.id}} = UUID.random unless m.persisted? - end + macro primary_key(name = "id", type = :bigserial) + {% type = "#{type.id}" %} + {% cb = PKEY_TYPE[type] %} + {% if cb %} + {{cb.gsub(/__name__/, name).id}} {% else %} - {% raise "with_serial_pkey: known type are :serial | :bigserial | :uuid" %} + { raise "Cannot define primary key of type #{type}. Candidates are: #{PKEY_TYPE.keys.join(", ")}" %} {% end %} end + + # Add a hook for the `primary_key` + # In the hook, __name__ will be replaced by the column name required by calling primary_key + # + # ## Example + # + # ``` + # Clear::Model::HasSerialPkey.add_pkey_type("awesomepkeysystem") do + # column __name__ : AwesomePkey, primary: true, presence: false + # + # before_validate do + # #... + # end + # end + # ``` + macro add_pkey_type(type, &block) + {% PKEY_TYPE[type] = "#{block.body}" %} + end + + add_pkey_type "bigserial" do + column __name__ : Int64, primary: true, presence: false + end + + add_pkey_type "serial" do + column __name__ : Int32, primary: true, presence: false + end + + add_pkey_type "text" do + column __name__ : String, primary: true, presence: true + end + + add_pkey_type "int" do + column __name__ : Int32, primary: true, presence: true + end + + add_pkey_type "bigint" do + column __name__ : Int64, primary: true, presence: true + end + end diff --git a/src/clear/model/modules/is_polymorphic.cr b/src/clear/model/modules/is_polymorphic.cr deleted file mode 100644 index 1d9f08a28..000000000 --- a/src/clear/model/modules/is_polymorphic.cr +++ /dev/null @@ -1,105 +0,0 @@ -module Clear::Model::IsPolymorphic - macro included - macro included - SETTINGS = {} of Nil => Nil - end - end - - abstract class Factory - abstract def build(h : Hash(String, ::Clear::SQL::Any), - cache : Clear::Model::QueryCache? = nil, - persisted = false, - fetch_columns = false) : Clear::Model - end - - macro included - macro included - class_getter? polymorphic : Bool = false - class_getter! factory : Factory - - # Add linking between classes for the EventManager triggers - macro inherited - \\{% for ancestor in @type.ancestors %} - \\{% if ancestor < Clear::Model %} - Clear::Model::EventManager.add_inheritance(\\{{ancestor}}, \\{{@type}}) - \\{% end %} - \\{% end %} - end - end - end - - # :nodoc: - # Define a simple model factory which is litteraly just a - # delegate to the constructor. - macro __init_default_factory - {% unless SETTINGS[:has_factory] %} - class Factory < ::Clear::Model::IsPolymorphic::Factory - def build(h : Hash(String, ::Clear::SQL::Any ), - cache : Clear::Model::QueryCache? = nil, - persisted = false, - fetch_columns = false) - {{@type}}.new(h, cache, persisted, fetch_columns) - end - end - - @@factory = Factory.new - {% end %} - end - - # Define a polymorphic factory, if the model is tagged as polymorphic - macro polymorphic(*class_list, through = "type") - {% SETTINGS[:has_factory] = true %} - {% if class_list.size == 0 %} - {% raise "Please setup subclass list for polymorphism." %} - {% end %} - - column {{through.id}} : String - - before(:validate) do |model| - model = model.as(self) - model.{{through.id}} = model.class.name - end - - # Subclasses are refined using a default scope - # to filter by type. - macro inherited - def self.query - Collection.new.from(table).where{ {{through.id}} == self.name } - end - end - - # Base class can be refined too, only if the baseclass is not abstract. - {% unless @type.abstract? %} - def self.query - Collection.new.from(table).where{ {{through.id}} == self.name } - end - {% end %} - - def self.polymorphic? - true - end - - class Factory < ::Clear::Model::Factory - def initialize(@through : String) - end - - def build(h : Hash(String, ::Clear::SQL::Any ), - cache : Clear::Model::QueryCache? = nil, - persisted = false, - fetch_columns = false) - case h[@through]? - {% for c in class_list %} - when {{c.id}}.name - {{c.id}}.new(h, cache, persisted, fetch_columns) - {% end %} - when nil - raise Clear::ErrorMessages.polymorphic_nil(@through) - else - raise Clear::ErrorMessages.polymorphic_unknown_class(h[@through]) - end - end - end - - @@factory = Factory.new({{through}}) - end -end diff --git a/src/clear/model/modules/relations/has_many_macro.cr b/src/clear/model/modules/relations/has_many_macro.cr index 852afa06b..2f54f0a16 100644 --- a/src/clear/model/modules/relations/has_many_macro.cr +++ b/src/clear/model/modules/relations/has_many_macro.cr @@ -25,7 +25,7 @@ module Clear::Model::Relations::HasManyMacro end query.add_operation = -> (x : {{relation_type}}) { - x.set(query.tags) + x.reset(query.tags) x.save! x } diff --git a/src/clear/model/modules/relations/has_many_through_macro.cr b/src/clear/model/modules/relations/has_many_through_macro.cr index d8158714b..a451b7aa9 100644 --- a/src/clear/model/modules/relations/has_many_through_macro.cr +++ b/src/clear/model/modules/relations/has_many_through_macro.cr @@ -39,7 +39,7 @@ module Clear::Model::Relations::HasManyThroughMacro {% if through.is_a?(Path) %} through_model = {{through}}.new - through_model.set({ + through_model.reset({ "#{%own_key}" => current_model_id, "#{%through_key}" => x.pkey }) @@ -53,6 +53,21 @@ module Clear::Model::Relations::HasManyThroughMacro x } + qry.unlink_operation = -> (x : {{relation_type}}) { + {% if through.is_a?(Path) %} + table = {{through}}.table + {% else %} + table = {{through.id.stringify}} + {% end %} + + Clear::SQL.delete(table).where({ + "#{%own_key}" => current_model_id, + "#{%through_key}" => x.pkey + }).execute + + x + } + qry end diff --git a/src/clear/model/validation/helper.cr b/src/clear/model/validation/helper.cr index 7ca42524f..97d8414af 100644 --- a/src/clear/model/validation/helper.cr +++ b/src/clear/model/validation/helper.cr @@ -1,10 +1,6 @@ module Clear::Validation::Helper - macro on_presence(field, &block) - if persisted? - if {{field.id}}_column.defined? - {{yield}} - end - else + macro on_presence(*fields, &block) + if {{ fields.map{ |x| "self.#{x.id}_column.defined?" }.join(" && ").id }} {{yield}} end end diff --git a/src/clear/sql/connection_pool.cr b/src/clear/sql/connection_pool.cr index 482447945..3103015fa 100644 --- a/src/clear/sql/connection_pool.cr +++ b/src/clear/sql/connection_pool.cr @@ -4,7 +4,7 @@ class Clear::SQL::ConnectionPool @@fiber_connections = {} of {String, Fiber} => { DB::Database, Int32 } def self.init(uri, name, pool_size) - raise "Pool size must be superior to 0" unless pool_size > 0 + raise "Connection pool size must be position" unless pool_size > 0 channel = @@connections[name] = Channel(DB::Database).new(capacity: pool_size) pool_size.times{ channel.send DB.open(uri) } end diff --git a/src/clear/sql/insert_query.cr b/src/clear/sql/insert_query.cr index 7230c35ed..de791c9e1 100644 --- a/src/clear/sql/insert_query.cr +++ b/src/clear/sql/insert_query.cr @@ -99,7 +99,7 @@ class Clear::SQL::InsertQuery case v = @values when SelectBuilder - raise "Cannot insert both from SELECT and from data" + raise "Cannot insert both from SELECT query and from data" when Array(Array(Inserable)) v << row.values.to_a.map(&.as(Inserable)) end @@ -112,7 +112,7 @@ class Clear::SQL::InsertQuery case v = @values when SelectBuilder - raise "Cannot insert both from SELECT and from data" + raise "Cannot insert both from SELECT query and from data" when Array(Array(Inserable)) v << row.values.to_a.map(&.as(Inserable)) end diff --git a/src/clear/sql/logger.cr b/src/clear/sql/logger.cr index b28b8c04a..44c0b3b21 100644 --- a/src/clear/sql/logger.cr +++ b/src/clear/sql/logger.cr @@ -59,7 +59,10 @@ module Clear::SQL::Logger o rescue e - STDERR.puts "Error caught, last query was:\n#{Clear::SQL::Logger.colorize_query(sql)}" - raise e + raise Clear::SQL::Error.new( + message: + [e.message,"Error caught, last query was:", Clear::SQL::Logger.colorize_query(sql)].compact.join("\n"), + cause: e + ) end end diff --git a/src/clear/sql/query/aggregate.cr b/src/clear/sql/query/aggregate.cr index 037e172a5..924721072 100644 --- a/src/clear/sql/query/aggregate.cr +++ b/src/clear/sql/query/aggregate.cr @@ -1,9 +1,10 @@ module Clear::SQL::Query::Aggregate + # Use SQL `COUNT` over your query, and return this number as a Int64 - # as count return always a scalar, the usage of `COUNT OVER GROUP BY` can be done by - # calling `agg` instead. # - # This return only a number of records !! + # as count return always a scalar, the usage of `COUNT(*) OVER GROUP BY` can be done by + # using `pluck` or `select` + # # def count(type : X.class = Int64) forall X # save the `select` column clause to ensure non-mutability of the query @@ -28,14 +29,25 @@ module Clear::SQL::Query::Aggregate o end - # Call an custom aggregation function, like MEDIAN or other + # Call an custom aggregation function, like MEDIAN or other: + # + # ``` + # query.agg("MEDIAN(age)", Int64) + # ``` + # # Note than COUNT, MIN, MAX and AVG are already conveniently mapped. + # + # This return only one row, and should not be used with `group_by` (prefer pluck or fetch) def agg(field, x : X.class) forall X self.clear_select.select(field).scalar(X) end {% for x in %w(min max avg) %} - # Call the SQL aggregation function {{x.upcase}} + # SQL aggregation function {{x.upcase}}: + # + # ``` + # query.{{x.id}}("field", Int64) + # ``` def {{x.id}}(field, x : X.class) forall X agg("{{x.id.upcase}}(#{field})", X) end diff --git a/src/clear/sql/query/pluck.cr b/src/clear/sql/query/pluck.cr new file mode 100644 index 000000000..105b5d720 --- /dev/null +++ b/src/clear/sql/query/pluck.cr @@ -0,0 +1,107 @@ +module Clear::SQL::Query::Pluck + # Select a specific column of your SQL query, execute the query + # and return an array containing this field. + # + # ```crystal + # User.query.pluck_col("id") # [1,2,3,4...] + # ``` + # + # Note: It returns an array of `Clear::SQL::Any`. Therefore, you may want to use `pluck_col(str, Type)` to return + # an array of `Type`: + # + # ```crystal + # User.query.pluck_col("id", Int64) + # ``` + # + # The field argument is a SQL fragment; it's not escaped (beware SQL injection) and allow call to functions + # and aggregate methods: + # + # ```crystal + # # ... + # User.query.pluck_col("CASE WHEN id % 2 = 0 THEN id ELSE NULL END AS id").each do + # # ... + # ``` + def pluck_col(field : String) + sql = self.clear_select.select(field).to_sql + rs = Clear::SQL.log_query(sql) { Clear::SQL::ConnectionPool.with_connection(connection_name, &.query(sql)) } + + o = [] of Clear::SQL::Any + + while rs.move_next + o << rs.read + end + o + ensure + rs.try &.close + end + + # See `pluck_col(field)` + def pluck_col(field : String, type : T.class ) forall T + sql = self.clear_select.select(field).to_sql + rs = Clear::SQL.log_query(sql) { Clear::SQL::ConnectionPool.with_connection(connection_name, &.query(sql)) } + + o = [] of T + + while rs.move_next + o << rs.read(T) + end + o + ensure + rs.try &.close + end + + # Select specifics columns and return an array of Tuple(*Clear::SQL::Any) containing the columns in the order of the selected + # arguments: + # + # ```crystal + # User.query.pluck("first_name", "last_name").each do |(first_name, last_name)| + # #... + # end + # ``` + def pluck(*fields) + pluck(fields) + end + + # Select specifics columns and returns on array of tuple of type of the named tuple passed as parameter: + # + # ```crystal + # User.query.pluck(id: Int64, "UPPER(last_name)": String).each do #... + # ``` + + def pluck(**fields : **T) forall T + sql = self.clear_select.select(fields.keys.join(", ")).to_sql + rs = Clear::SQL.log_query(sql) { Clear::SQL::ConnectionPool.with_connection(connection_name, &.query(sql)) } + + {% begin %} + o = [] of Tuple({% for k,v in T %}{{v.instance}},{% end %}) + + while rs.move_next + o << { {% for k,v in T %} rs.read({{v.instance}}), {% end %}} + end + o + {% end %} + ensure + rs.try &.close + end + + # See `pluck(*fields)` + def pluck(fields : Tuple(*T)) forall T + sql = self.clear_select.select(fields.join(", ")).to_sql + rs = Clear::SQL.log_query(sql) { Clear::SQL::ConnectionPool.with_connection(connection_name, &.query(sql)) } + + {% begin %} + o = [] of Tuple({% for t in T %}Clear::SQL::Any,{% end %}) + + while rs.move_next + o << { + {% for t in T %} + rs.read, + {% end %} + } + end + o + {% end %} + ensure + rs.try &.close + end +end \ No newline at end of file diff --git a/src/clear/sql/query/where.cr b/src/clear/sql/query/where.cr index 1c4ff2001..03e9f7753 100644 --- a/src/clear/sql/query/where.cr +++ b/src/clear/sql/query/where.cr @@ -36,6 +36,7 @@ module Clear::SQL::Query::Where where(Clear::Expression.ensure_node!(with Clear::Expression.new yield)) end + def where(**tuple) where(conditions: tuple) end @@ -58,7 +59,7 @@ module Clear::SQL::Query::Where # ```crystal # query.where({x: another_select}) # WHERE x IN (SELECT ... ) # ``` - def where(conditions : NamedTuple) + def where(conditions : NamedTuple | Hash(String, Clear::SQL::Any)) conditions.each do |k, v| k = Clear::Expression::Node::Variable.new(k.to_s) @@ -83,6 +84,7 @@ module Clear::SQL::Query::Where change! end + # Build SQL `where` condition using a template string and # interpolating `?` characters with parameters given in a tuple or array. # ```crystal diff --git a/src/clear/sql/select_builder.cr b/src/clear/sql/select_builder.cr index 4e03d01a1..c8915b35f 100644 --- a/src/clear/sql/select_builder.cr +++ b/src/clear/sql/select_builder.cr @@ -1,24 +1,32 @@ require "./query/**" module Clear::SQL::SelectBuilder - include Query::Change - include Query::Connection + include Query::Select include Query::From include Query::Join + include Query::Where + include Query::Having + include Query::OrderBy include Query::GroupBy - include Query::Having - include Query::Window include Query::OffsetLimit - include Query::Execute + include Query::Aggregate + + include Query::CTE + include Query::Window include Query::Lock + + + include Query::Execute include Query::Fetch + include Query::Pluck + + include Query::Connection + include Query::Change include Query::BeforeQuery - include Query::CTE include Query::WithPagination - include Query::Aggregate def initialize(@distinct_value = nil, @cte = {} of String => Clear::SQL::SelectBuilder | String, diff --git a/src/clear/sql/select_query.cr b/src/clear/sql/select_query.cr index 5e908fe7d..a316b053d 100644 --- a/src/clear/sql/select_query.cr +++ b/src/clear/sql/select_query.cr @@ -4,7 +4,9 @@ require "./sql" # A Select Query builder # -# Cf. Postgres documentation +# Postgres documentation: +# +# ``` # [ WITH [ RECURSIVE ] with_query [, ...] ] # SELECT [ ALL | DISTINCT [ ON ( expression [, ...] ) ] ] # [ * | expression [ [ AS ] output_name ] [, ...] ] @@ -19,7 +21,7 @@ require "./sql" # [ OFFSET start [ ROW | ROWS ] ] # [ FETCH { FIRST | NEXT } [ count ] { ROW | ROWS } ONLY ] # [ FOR { UPDATE | NO KEY UPDATE | SHARE | KEY SHARE } [ OF table_name [, ...] ] [ NOWAIT | SKIP LOCKED ] [...] ] -# +# ``` class Clear::SQL::SelectQuery include Enumerable(Hash(String, Clear::SQL::Any)) include SelectBuilder diff --git a/src/clear/sql/sql.cr b/src/clear/sql/sql.cr index 24eee7e0e..5a566529b 100644 --- a/src/clear/sql/sql.cr +++ b/src/clear/sql/sql.cr @@ -41,7 +41,7 @@ module Clear alias Any = Array(PG::BoolArray) | Array(PG::CharArray) | Array(PG::Float32Array) | Array(PG::Float64Array) | Array(PG::Int16Array) | Array(PG::Int32Array) | Array(PG::Int64Array) | Array(PG::StringArray) | Bool | Char | Float32 | - Float64 | Int8 | Int16 | Int32 | Int64 | JSON::Any | PG::Geo::Box | PG::Geo::Circle | + Float64 | Int8 | Int16 | Int32 | Int64 | JSON::Any | JSON::Any::Type | PG::Geo::Box | PG::Geo::Circle | PG::Geo::Line | PG::Geo::LineSegment | PG::Geo::Path | PG::Geo::Point | PG::Geo::Polygon | PG::Numeric | Slice(UInt8) | String | Time | UInt8 | UInt16 | UInt32 | UInt64 | Clear::Expression::UnsafeSql | Nil diff --git a/src/clear/version.cr b/src/clear/version.cr index 081648e2d..36ca95468 100644 --- a/src/clear/version.cr +++ b/src/clear/version.cr @@ -1,3 +1,3 @@ module Clear - VERSION = "v0.5" + VERSION = "v0.6" end