├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── bin └── .gitkeep ├── db └── .gitkeep ├── samples ├── association.cr ├── default.cr ├── json.cr ├── migration.cr ├── model.cr ├── nullable.cr ├── time.cr └── transaction.cr ├── shard.yml ├── spec ├── db_spec.cr ├── model │ ├── migration │ │ ├── mysql_before.cr │ │ ├── mysql_exec.cr │ │ ├── pg_before.cr │ │ ├── pg_exec.cr │ │ ├── spec_for_migrations.cr │ │ ├── sqlite3_before.cr │ │ └── sqlite3_exec.cr │ ├── models.cr │ ├── mysql.cr │ ├── pg.cr │ ├── specs_for_models.cr │ └── sqlite3.cr └── spec_helper.cr └── src ├── topaz.cr └── topaz ├── db.cr ├── logger.cr ├── model.cr ├── nilwrapper.cr └── version.cr /.gitignore: -------------------------------------------------------------------------------- 1 | /doc/ 2 | /libs/ 3 | /lib/ 4 | /.crystal/ 5 | /.shards/ 6 | /bin/* 7 | /db/* 8 | 9 | !.gitkeep 10 | 11 | # Libraries don't need dependency lock 12 | # Dependencies will be locked in application that uses them 13 | /shard.lock 14 | 15 | .DS_Store 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | crystal: 3 | - latest 4 | env: 5 | - TRAVIS_POSTGRESQL_VERSION=9.6 6 | - TRAVIS_POSTGRESQL_VERSION=9.5 7 | - TRAVIS_POSTGRESQL_VERSION=9.4 8 | - TRAVIS_POSTGRESQL_VERSION=9.3 9 | - TRAVIS_POSTGRESQL_VERSION=9.2 10 | before_install: 11 | - mysql -uroot -e "create database topaz_test" 12 | - sudo apt-get install python-software-properties 13 | - sudo apt-get -y update 14 | - sudo apt-cache show sqlite3 15 | - sudo apt-get install sqlite3 16 | - sudo sqlite3 -version 17 | - sudo service postgresql stop 18 | - sudo service postgresql start $TRAVIS_POSTGRESQL_VERSION 19 | - createuser root 20 | - createdb topaz_test 21 | - export DATABASE_URL=postgres://root@localhost/topaz_test 22 | script: make 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Taichiro Suzuki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: spec sample 2 | 3 | spec: basic migration 4 | 5 | basic: 6 | crystal spec 7 | crystal spec ./spec/model/sqlite3.cr 8 | crystal spec ./spec/model/mysql.cr 9 | crystal spec ./spec/model/pg.cr 10 | 11 | migration: mig-test-sqlite3 mig-test-mysql mig-test-pg 12 | 13 | mig-test-sqlite3: 14 | crystal spec ./spec/model/migration/sqlite3_before.cr 15 | crystal spec ./spec/model/migration/sqlite3_exec.cr 16 | 17 | mig-test-mysql: 18 | crystal spec ./spec/model/migration/mysql_before.cr 19 | crystal spec ./spec/model/migration/mysql_exec.cr 20 | 21 | mig-test-pg: 22 | crystal spec ./spec/model/migration/pg_before.cr 23 | crystal spec ./spec/model/migration/pg_exec.cr 24 | 25 | sample: 26 | crystal run ./samples/model.cr 27 | crystal run ./samples/association.cr 28 | crystal run ./samples/json.cr 29 | crystal run ./samples/transaction.cr 30 | crystal run ./samples/migration.cr 31 | crystal run ./samples/time.cr 32 | crystal run ./samples/nullable.cr 33 | crystal run ./samples/default.cr 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Topaz [![Build Status](https://travis-ci.org/topaz-crystal/topaz.svg?branch=master)](https://travis-ci.org/topaz-crystal/topaz) [![GitHub release](https://img.shields.io/github/release/topaz-crystal/topaz.svg)]() 4 | [![Dependency Status](https://shards.rocks/badge/github/topaz-crystal/topaz/status.svg)](https://shards.rocks/github/topaz-crystal/topaz) 5 | [![devDependency Status](https://shards.rocks/badge/github/topaz-crystal/topaz/dev_status.svg)](https://shards.rocks/github/topaz-crystal/topaz) 6 | 7 | Topaz is a simple and useful db wrapper for crystal lang. 8 | Topaz is inspired by active record design pattern, but not fully implemented. 9 | See [sample code](https://github.com/topaz-crystal/topaz/blob/master/samples) for detail. 10 | [Here](https://github.com/topaz-crystal/topaz-kemal-sample) is another sample that shows how Topaz works in Kemal. 11 | Depends on [crystal-lang/crystal-mysql](https://github.com/crystal-lang/crystal-mysql), [crystal-lang/crystal-sqlite3](https://github.com/crystal-lang/crystal-sqlite3) and [crystal-pg](https://github.com/will/crystal-pg) 12 | 13 | ## Usage 14 | 15 | **1. Setup DB** 16 | ```crystal 17 | Topaz::Db.setup("mysql://root@localhost/topaz") # For MySQL 18 | Topaz::Db.setup("postgres://root@localhost/topaz") # For PostgreSQL 19 | Topaz::Db.setup("sqlite3://./db/data.db") # For SQLite3 20 | ``` 21 | 22 | **2. Define models** 23 | ```crystal 24 | class SampleModel < Topaz::Model 25 | columns( 26 | name: String 27 | ) 28 | end 29 | 30 | # You can drop or create a table 31 | SampleModel.create_table 32 | SampleModel.drop_table 33 | ``` 34 | 35 | **3. Create, find, update and delete models** 36 | ```crystal 37 | s = SampleModel.create("Sample Name") 38 | 39 | SampleModel.find(1).name 40 | # => "Sample Name" 41 | SampleModel.where("name = 'Sample Name'").size 42 | # => 1 43 | ``` 44 | See [sample code](https://github.com/topaz-crystal/topaz/blob/master/samples/model.cr) for detail. 45 | 46 | **4. Define associations between models** 47 | ```crystal 48 | require "topaz" 49 | 50 | class SampleParent < Topaz::Model 51 | columns # Empty columns 52 | has_many(children: {model: SampleChild, key: parent_id}) 53 | end 54 | 55 | class SampleChild < Topaz::Model 56 | columns( # Define foreign key 57 | parent_id: Int32 58 | ) 59 | belongs_to(parent: {model: SampleParent, key: parent_id}) 60 | end 61 | 62 | p = SampleParent.create 63 | 64 | child1 = SampleChild.create(p.id) 65 | child2 = SampleChild.create(p.id) 66 | child3 = SampleChild.create(p.id) 67 | 68 | p.children.size 69 | # => 3 70 | 71 | child1.parent.id 72 | # => 1 73 | ``` 74 | See [sample code](https://github.com/topaz-crystal/topaz/blob/master/samples/association.cr) for detail. 75 | 76 | **Other features** 77 | * Transaction 78 | * Table migration 79 | * `Model#to_json` and `Model#from_json` 80 | * `created_at` and `updated_at` column 81 | * Nullable column 82 | * Column with default value 83 | * Change id from Int32 to Int64 84 | 85 | See [sample codes](https://github.com/topaz-crystal/topaz/tree/master/samples) for detail. 86 | 87 | **Supported data types.** 88 | String, Int32, Int64, Float32, Float64 89 | 90 | ## Development 91 | 92 | Setting up PostgreSQL: 93 | 94 | ``` 95 | $ psql 96 | # CREATE USER root WITH CREATEDB; 97 | # CREATE DATABASE topaz_test WITH OWNER = root; 98 | ``` 99 | 100 | Setting up MySQL: 101 | 102 | ``` 103 | $ mysql -u root 104 | mysql> create database topaz_test; 105 | ``` 106 | 107 | ## Contributing 108 | 109 | 1. Fork it ( https://github.com/topaz-crystal/topaz/fork ) 110 | 2. Create your feature branch (git checkout -b my-new-feature) 111 | 3. Commit your changes (git commit -am 'Add some feature') 112 | 4. Push to the branch (git push origin my-new-feature) 113 | 5. Create a new Pull Request 114 | 115 | ## Contributors 116 | 117 | - [tbrand](https://github.com/tbrand) Taichiro Suzuki - creator, maintainer 118 | -------------------------------------------------------------------------------- /bin/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topaz-crystal/topaz/5f569ee0b8c3993664d0748d25141a2c8034ceec/bin/.gitkeep -------------------------------------------------------------------------------- /db/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/topaz-crystal/topaz/5f569ee0b8c3993664d0748d25141a2c8034ceec/db/.gitkeep -------------------------------------------------------------------------------- /samples/association.cr: -------------------------------------------------------------------------------- 1 | require "../src/topaz" 2 | require "sqlite3" 3 | 4 | ###################### 5 | # Association Sample # 6 | ###################### 7 | 8 | # You can define associations for each model 9 | # For now, let me define 2 models 10 | class SampleParent < Topaz::Model 11 | columns # Empty columns 12 | # This meant that SampleParent has multiple SampleChilds 13 | # You can access it as children where parent_id of the children equals to the id 14 | has_many( 15 | children: {model: SampleChild, key: parent_id} 16 | ) 17 | end 18 | 19 | class SampleChild < Topaz::Model 20 | # Define foreign key 21 | columns( 22 | parent_id: Int64 23 | ) 24 | # This meant that SampleChild belongs to a SampleParent 25 | # You can access SampleParent as parent where id of it equals to parent_id 26 | belongs_to( 27 | parent: {model: SampleParent, key: parent_id} 28 | ) 29 | end 30 | 31 | # Setup db 32 | Topaz::Db.setup("sqlite3://./db/sample.db") 33 | 34 | # Setup tables 35 | SampleParent.drop_table 36 | SampleParent.create_table 37 | SampleChild.drop_table 38 | SampleChild.create_table 39 | 40 | # Let me create a parent 41 | p = SampleParent.create 42 | 43 | # Here we create 3 children belong to the parent 44 | child1 = SampleChild.create(p.id.to_i64) 45 | child2 = SampleChild.create(p.id.to_i64) 46 | child3 = SampleChild.create(p.id.to_i64) 47 | 48 | # Select all children 49 | p.children.size 50 | # => 3 51 | p.children.first.id 52 | # => 1 53 | 54 | # Find a parent 55 | child1.parent.id 56 | # => 1 57 | -------------------------------------------------------------------------------- /samples/default.cr: -------------------------------------------------------------------------------- 1 | require "../src/topaz" 2 | require "sqlite3" 3 | 4 | ############################# 5 | # Column with default value # 6 | ############################# 7 | # You can set default value for columns 8 | class DefaultModel < Topaz::Model 9 | columns( 10 | without_default: String, 11 | with_default: {type: String, default: "default value"}, 12 | with_default2: {type: String, default: "default value 2"}, 13 | with_nil_default: {type: String, nullable: true}, # The default value is `nil` 14 | ) 15 | # **Note** 16 | # Please define columns WITHOUT default values first. 17 | # So following code is NOT allowed 18 | # ``` 19 | # columns( 20 | # with_default: {type: String, default: "default value"}, 21 | # without_default: String, <- This column should be defined at first 22 | # with_default2: {type: String, default: "default value 2"}, 23 | # with_nil_default: {type: String, nullable: true}, 24 | # ) 25 | # ``` 26 | # See the discussion here: https://github.com/topaz-crystal/topaz/issues/8 27 | end 28 | 29 | Topaz::Db.setup("sqlite3://./db/sample.db") 30 | 31 | # Setup table 32 | DefaultModel.drop_table 33 | DefaultModel.create_table 34 | 35 | # You can create models with default values like this 36 | DefaultModel.create("val0", "val1", "val2") # <- with_nil_default is "nil" 37 | DefaultModel.create("val0", "val1") # <- with_nil_default is "nil" and with_default2 is "default value 2" 38 | DefaultModel.create("val0") # <- with_nil_default is "nil", with_default is "default value" and with_default2 is "default value 2" 39 | 40 | DefaultModel.find(3).with_default2 41 | # => "default value 2" 42 | -------------------------------------------------------------------------------- /samples/json.cr: -------------------------------------------------------------------------------- 1 | require "../src/topaz" 2 | require "sqlite3" 3 | 4 | ############### 5 | # Json Sample # 6 | ############### 7 | 8 | # In this sample, we have 9 | # JsonParent 10 | # - JsonChild 11 | 12 | class JsonParent < Topaz::Model 13 | columns(name: String) 14 | has_many( 15 | children: {model: JsonChild, key: p_id}, 16 | ) 17 | end 18 | 19 | class JsonChild < Topaz::Model 20 | columns( 21 | age: Int64, 22 | p_id: Int64 23 | ) 24 | belongs_to(parent: {model: JsonParent, key: p_id}) 25 | end 26 | 27 | Topaz::Db.setup("sqlite3://./db/sample.db") 28 | 29 | JsonParent.drop_table 30 | JsonChild.drop_table 31 | 32 | JsonParent.create_table 33 | JsonChild.create_table 34 | 35 | p = JsonParent.create("John") 36 | 37 | c1 = JsonChild.create(12i64, p.id.to_i64) 38 | c2 = JsonChild.create(15i64, p.id.to_i64) 39 | c3 = JsonChild.create(23i64, p.id.to_i64) 40 | 41 | # output of created_at and udpated_at columns are just examples 42 | p.to_json 43 | # => {"id":1,"name":"John","created_at":"2016-12-26T02:47:34+0900","updated_at":"2016-12-26T02:47:34+0900"} 44 | c1.to_json 45 | # => {"id":1,"age":12,"p_id":1,"created_at":"2016-12-26T02:47:34+0900","updated_at":"2016-12-26T02:47:34+0900"} 46 | c2.to_json 47 | # => {"id":2,"age":15,"p_id":1,"created_at":"2016-12-26T02:47:34+0900","updated_at":"2016-12-26T02:47:34+0900"} 48 | c3.to_json 49 | # => {"id":3,"age":23,"p_id":1,"created_at":"2016-12-26T02:47:34+0900","updated_at":"2016-12-26T02:47:34+0900"} 50 | 51 | # id is not nullable 52 | # id == -1 meant that the instance is not saved 53 | c4 = JsonParent.from_json("{\"id\": -1, \"name\": \"Who\"}") 54 | c4.to_json 55 | # => {"id":-1,"name":"Who"} 56 | c4.save 57 | c4.to_json 58 | # => {"id":2,"name":"Who","created_at":"2016-12-26T02:47:34+0900","updated_at":"2016-12-26T02:47:34+0900"} 59 | -------------------------------------------------------------------------------- /samples/migration.cr: -------------------------------------------------------------------------------- 1 | require "../src/topaz" 2 | 3 | # Topaz::Db.setup("sqlite3://./db/sample.db") 4 | # 5 | ################### 6 | # Table Migration # 7 | ################### 8 | # 9 | # Migration is useful in the following case 10 | # 1. You'd already defined and created a model and columns 11 | # 2. You've added or removed columns from the defined model 12 | # 3. You want to keep data of remaining columns 13 | # 14 | # We assume the case that we've already defined 15 | # 16 | # [Defined Model] 17 | # class MigrationSample < Topaz::Model 18 | # columns( 19 | # name: String, 20 | # age: Int32, 21 | # ) 22 | # end 23 | # 24 | # And you'd created a table 25 | # MigrationSample.drop_table 26 | # MigrationSample.create_table 27 | # 28 | # MigrationSample.create("SampleName", 25) 29 | # 30 | # After that, you've removed 'age' column and added score column like 31 | # 32 | # [Redefined model] 33 | # class MigrationSample < Topaz::Model 34 | # columns( 35 | # name: String, 36 | # score: Int32, 37 | # ) 38 | # end 39 | # 40 | # In this case, you can call Topaz::Model#migrate_table to keep the remaining data like 41 | # MigrationSample.migrate_table 42 | # MigrationSample.find(1).name 43 | # => "SampleName" 44 | -------------------------------------------------------------------------------- /samples/model.cr: -------------------------------------------------------------------------------- 1 | require "../src/topaz" 2 | require "sqlite3" 3 | 4 | ################ 5 | # Model Sample # 6 | ################ 7 | 8 | # This is a sample for Topaz::Model using SQLite3 as db 9 | # You can define columns for your model as follows 10 | class SampleModel < Topaz::Model 11 | # Basically, each column needs 'name' and 'type'. 12 | # Currently, String, Int32, Int64, Float32 and Float64 are supported 13 | # In this sample, we use SQLite3 as database. 14 | columns( 15 | name: String, 16 | age: Int64, 17 | score: Float64, 18 | ) 19 | end 20 | 21 | # Setup db 22 | Topaz::Db.setup("sqlite3://./db/sample.db") 23 | 24 | # Setup tables 25 | # You can create or drop a table as follows 26 | # Actually, these calls should be defined in other files such as migration.cr 27 | SampleModel.drop_table 28 | SampleModel.create_table 29 | 30 | # Here, we create 7 models. 31 | aaa = SampleModel.create("AAA", 25.to_i64, 20.0) 32 | bbb = SampleModel.create("BBB", 26.to_i64, 32.0) 33 | ccc = SampleModel.create("CCC", 25.to_i64, 40.0) 34 | ddd = SampleModel.create("DDD", 27.to_i64, 41.0) 35 | eee = SampleModel.create("EEE", 24.to_i64, 42.0) 36 | fff = SampleModel.create("FFF", 22.to_i64, 45.0) 37 | ggg = SampleModel.create("GGG", 25.to_i64, 18.0) 38 | 39 | # Select all models we created 40 | SampleModel.select.size 41 | # => 7 42 | 43 | # You can specify id to find a model 44 | SampleModel.find(1).name 45 | # => AAA 46 | 47 | # Select models where it's age equals 25 48 | SampleModel.where("age = 25").select.size 49 | # => 3 50 | 51 | # Note that when you specify string as searched query, single quates are needed 52 | SampleModel.where("name = 'AAA'").select.size 53 | # => 1 54 | 55 | # Select samples ordered by 'score' and set offset = 1 and limit = 3 56 | SampleModel.order("score").range(1, 3).select.first.name 57 | # => AAA 58 | 59 | # Update name from AAA to AAA+ 60 | aaa.name = "AAA+" 61 | aaa.update 62 | aaa.name 63 | # => AAA+ 64 | 65 | # You can update columns by using Hash (NamedTuple actually) 66 | bbb.update(name: "BBB+") 67 | bbb.name 68 | # => BBB+ 69 | 70 | # Delete a model 71 | ggg.delete 72 | SampleModel.select.size 73 | # => 6 74 | 75 | # Delete all models 76 | SampleModel.delete 77 | SampleModel.select.size 78 | # => 0 79 | -------------------------------------------------------------------------------- /samples/nullable.cr: -------------------------------------------------------------------------------- 1 | require "../src/topaz" 2 | require "sqlite3" 3 | 4 | ################### 5 | # Nullable Column # 6 | ################### 7 | # You can specify nullable or not nullable for each column 8 | # The default value of nullable column is `nil`. 9 | # You have to define NOT nullable columns first. 10 | # See a sample at `sample/default.cr` for details. 11 | class NullableModel < Topaz::Model 12 | columns( 13 | name: String, 14 | name_not_nullable: {type: String, nullable: false}, 15 | name_nullable: {type: String, nullable: true}, 16 | time_nullable: {type: Time, nullable: true} 17 | ) 18 | end 19 | 20 | Topaz::Db.setup("sqlite3://./db/sample.db") 21 | 22 | # Setup table 23 | NullableModel.drop_table 24 | NullableModel.create_table 25 | 26 | # You can create the model with null column 27 | NullableModel.create("name0", "name1", nil, nil) 28 | 29 | # Or you can omit the nil column (The value will be nil) 30 | NullableModel.create("name0", "name1") 31 | 32 | n = NullableModel.find(1) 33 | n.name_nullable.nil? 34 | # => true 35 | -------------------------------------------------------------------------------- /samples/time.cr: -------------------------------------------------------------------------------- 1 | require "../src/topaz" 2 | require "sqlite3" 3 | 4 | ############################# 5 | # created_at and updated_at # 6 | ############################# 7 | 8 | # Topaz is defined created_at(Time) and updated_at(Time) by default 9 | # You can access them after create or update model. 10 | # (updated_at is also saved when you create it.) 11 | # Time format is defined at Topaz::Model::TIME_FORMAT. 12 | 13 | class TimeModel < Topaz::Model 14 | columns( 15 | finished_at: Time 16 | ) 17 | end 18 | 19 | Topaz::Db.setup("sqlite3://./db/sample.db") 20 | 21 | TimeModel.drop_table 22 | TimeModel.create_table 23 | 24 | # create a model 25 | TimeModel.create(finished_at: Time.now) 26 | 27 | t = TimeModel.find(1) 28 | t.created_at 29 | # => 2016-12-26 00:32:59 +0900 (just as an example) 30 | typeof(t.created_at) 31 | # => Time|Nil 32 | t.updated_at 33 | # => 2016-12-26 00:32:59 +0900 (just as an example) 34 | typeof(t.updated_at) 35 | # => Time|Nil 36 | t.finished_at 37 | typeof(t.finished_at) 38 | t.finished_at -= Time::Span.new(5, 0, 0, 0) 39 | 40 | sleep 1 # wait 1 sec 41 | t.update 42 | 43 | span = t.updated_at.as(Time) - t.created_at.as(Time) 44 | span.seconds >= 1 45 | # => true 46 | -------------------------------------------------------------------------------- /samples/transaction.cr: -------------------------------------------------------------------------------- 1 | require "../src/topaz" 2 | require "sqlite3" 3 | 4 | ###################### 5 | # Transaction Sample # 6 | ###################### 7 | 8 | # Transaction is supported for Topaz::Model 9 | # Here we define a simple model 10 | class TransactionSample < Topaz::Model 11 | columns(name: String) 12 | end 13 | 14 | Topaz::Db.setup("sqlite3://./db/sample.db") 15 | 16 | TransactionSample.drop_table 17 | TransactionSample.create_table 18 | 19 | # In transaction, we use `in` method to pass the connection to models 20 | # To open a transaction we do like this 21 | # Topaz::Db.shared is a DB::Database instance that you set 22 | Topaz::Db.shared.transaction do |tx| 23 | # Here is in transaction 24 | # All operation can be rollbacked if some errors happen 25 | TransactionSample.in(tx).create("sample0") 26 | TransactionSample.in(tx).create("sample1") 27 | # You can find models by 28 | TransactionSample.in(tx).find(1).name 29 | # => sample0 30 | TransactionSample.in(tx).where("name = \'sample1\'").select.size 31 | # => 1 32 | # You can update them 33 | t0 = TransactionSample.in(tx).find(1) 34 | t0.in(tx).update(name: "sample0 updated") 35 | TransactionSample.in(tx).find(1).name 36 | # => sample0 updated 37 | # You can delete them 38 | t1 = TransactionSample.in(tx).find(2) 39 | t1.in(tx).delete 40 | TransactionSample.in(tx).select.size 41 | # => 1 42 | 43 | # You cannot call mutiple database operation at the same time like 44 | # TransactionSample.in(tx).find(1).update(name: "error!") 45 | # Because found model by `find(1)` is not in the transaction. 46 | # So it should be like 47 | # TransactionSample.in(tx).find(1).in(tx).update(name: "Safe call!") 48 | end 49 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: topaz 2 | version: 0.2.7 3 | 4 | authors: 5 | - Taichiro Suzuki 6 | 7 | license: MIT 8 | 9 | dependencies: 10 | singleton: 11 | github: topaz-crystal/crystal-singleton 12 | db: 13 | github: crystal-lang/crystal-db 14 | 15 | development_dependencies: 16 | mysql: 17 | github: crystal-lang/crystal-mysql 18 | branch: master 19 | sqlite3: 20 | github: crystal-lang/crystal-sqlite3 21 | branch: master 22 | pg: 23 | github: will/crystal-pg 24 | -------------------------------------------------------------------------------- /spec/db_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | require "sqlite3" 3 | 4 | describe Topaz do 5 | it "Setup db without any errors" do 6 | Topaz::Db.setup("sqlite3://./db/sample.db") 7 | Topaz::Db.close 8 | end 9 | 10 | it "Close db before opening" do 11 | expect_raises Exception do 12 | Topaz::Db.close 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/model/migration/mysql_before.cr: -------------------------------------------------------------------------------- 1 | require "./spec_for_migrations.cr" 2 | require "mysql" 3 | before_migration("mysql://root@localhost/topaz_test") 4 | -------------------------------------------------------------------------------- /spec/model/migration/mysql_exec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_for_migrations.cr" 2 | require "mysql" 3 | exec_migration("mysql://root@localhost/topaz_test") 4 | -------------------------------------------------------------------------------- /spec/model/migration/pg_before.cr: -------------------------------------------------------------------------------- 1 | require "./spec_for_migrations.cr" 2 | require "pg" 3 | before_migration("postgres://root@localhost/topaz_test") 4 | -------------------------------------------------------------------------------- /spec/model/migration/pg_exec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_for_migrations.cr" 2 | require "pg" 3 | exec_migration("postgres://root@localhost/topaz_test") 4 | -------------------------------------------------------------------------------- /spec/model/migration/spec_for_migrations.cr: -------------------------------------------------------------------------------- 1 | require "../../spec_helper" 2 | 3 | macro before_migration(db) 4 | 5 | Topaz::Db.setup("{{db.id}}") 6 | 7 | class MigTest < Topaz::Model 8 | columns( 9 | col0: String, 10 | col1: {type: Int32, nullable: true}, 11 | col2: {type: Float64, nullable: true}, 12 | ) 13 | end 14 | 15 | describe "Before Migration" do 16 | it "create original table" do 17 | MigTest.drop_table 18 | MigTest.create_table 19 | 20 | m0 = MigTest.create("name0", 10, 2.3) 21 | m1 = MigTest.create("name1", 11, 2.4) 22 | m2 = MigTest.create("name2", 12, 2.5) 23 | 24 | m0.id.should eq(1) 25 | m1.id.should eq(2) 26 | m2.id.should eq(3) 27 | 28 | m0.col0.should eq("name0") 29 | 30 | _m0 = MigTest.find(1) 31 | _m0.col0.should eq("name0") 32 | end 33 | end 34 | end 35 | 36 | macro exec_migration(db) 37 | 38 | Topaz::Db.setup("{{db.id}}") 39 | 40 | class MigTest < Topaz::Model 41 | columns( 42 | col0: String, 43 | col0_1: String, # added column 44 | col0_2: Int32, # added column 45 | col1: {type: Int32, nullable: true}, 46 | col1_5: {type: String, nullable: true}, # added column 47 | # col2: {type: Float64, nullable: true}, # removed column 48 | ) 49 | end 50 | 51 | describe "Execute migration" do 52 | it "migrate table" do 53 | MigTest.migrate_table 54 | m0 = MigTest.create("mname0", "added column1", 33, 13, "1_5") 55 | m1 = MigTest.create("mname1", "added column2", 34, 14, "1_6") 56 | 57 | m0.id.should eq(4) 58 | m1.id.should eq(5) 59 | 60 | m0.col1_5.should eq("1_5") 61 | m1.col0_2.should eq(34) 62 | 63 | m2 = MigTest.find(4) 64 | m2.col0.should eq("mname0") 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/model/migration/sqlite3_before.cr: -------------------------------------------------------------------------------- 1 | require "./spec_for_migrations.cr" 2 | require "sqlite3" 3 | before_migration("sqlite3://./db/sample.db") 4 | -------------------------------------------------------------------------------- /spec/model/migration/sqlite3_exec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_for_migrations.cr" 2 | require "sqlite3" 3 | exec_migration("sqlite3://./db/sample.db") 4 | -------------------------------------------------------------------------------- /spec/model/models.cr: -------------------------------------------------------------------------------- 1 | # Define models for every tests 2 | 3 | class EmptyColumn < Topaz::Model 4 | columns 5 | end 6 | 7 | class AllTypes < Topaz::Model 8 | columns( 9 | type_string: String, 10 | type_integer: Int32, 11 | type_float: Float32, 12 | type_double: Float64, 13 | type_time: Time, 14 | ) 15 | end 16 | 17 | class IdInt64 < Topaz::Model 18 | columns( 19 | id: Int64, 20 | ) 21 | end 22 | 23 | class SearchedModel < Topaz::Model 24 | columns( 25 | name: String, 26 | age: Int32, 27 | ) 28 | end 29 | 30 | class UpdatedModel < Topaz::Model 31 | columns( 32 | name: String, 33 | age: Int32, 34 | ) 35 | end 36 | 37 | class DeletedModel < Topaz::Model 38 | columns( 39 | name: String, 40 | age: Int32, 41 | ) 42 | end 43 | 44 | class NullableModel < Topaz::Model 45 | columns( 46 | test0: String, 47 | test1: {type: Int32, nullable: false}, 48 | test2: {type: Float64, nullable: true}, 49 | ) 50 | end 51 | 52 | class DefaultModel < Topaz::Model 53 | columns( 54 | test0: String, 55 | test1: {type: String, default: "OK1"}, 56 | test2: {type: String, default: "OK2", nullable: true}, 57 | test3: {type: String, nullable: true}, 58 | ) 59 | end 60 | 61 | class JsonParent < Topaz::Model 62 | columns(name: String) 63 | has_many( 64 | children: {model: JsonChild, key: p_id} 65 | ) 66 | end 67 | 68 | class JsonChild < Topaz::Model 69 | columns( 70 | age: Int32, 71 | p_id: Int32 72 | ) 73 | belongs_to(parent: {model: JsonParent, key: p_id}) 74 | end 75 | 76 | class TransactionModel < Topaz::Model 77 | columns(name: String) 78 | end 79 | 80 | class PersistenceModel < Topaz::Model 81 | columns(persist: String) 82 | end 83 | -------------------------------------------------------------------------------- /spec/model/mysql.cr: -------------------------------------------------------------------------------- 1 | require "mysql" 2 | require "./specs_for_models" 3 | select_db("mysql://root@localhost/topaz_test") 4 | -------------------------------------------------------------------------------- /spec/model/pg.cr: -------------------------------------------------------------------------------- 1 | require "pg" 2 | require "./specs_for_models" 3 | select_db("postgres://root@localhost/topaz_test") 4 | -------------------------------------------------------------------------------- /spec/model/specs_for_models.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | require "./models" 3 | 4 | macro select_db(db) 5 | Topaz::Db.setup("{{db.id}}") 6 | 7 | describe Topaz do 8 | 9 | it "Empty column" do 10 | EmptyColumn.drop_table 11 | EmptyColumn.create_table 12 | e = EmptyColumn.create 13 | e.id.should eq(1) 14 | EmptyColumn.select.size.should eq(1) 15 | EmptyColumn.select.first.id.should eq(1) 16 | end 17 | 18 | it "created_at and udpated_at" do 19 | EmptyColumn.drop_table 20 | EmptyColumn.create_table 21 | e0 = EmptyColumn.create 22 | e0.id.should eq(1) 23 | e1 = EmptyColumn.find(1) 24 | e0.created_at.as(Time).to_s(Topaz::Db.time_format).should eq(e1.created_at.as(Time).to_s(Topaz::Db.time_format)) 25 | e0.updated_at.as(Time).to_s(Topaz::Db.time_format).should eq(e1.updated_at.as(Time).to_s(Topaz::Db.time_format)) 26 | sleep 1 27 | e0.update 28 | e2 = EmptyColumn.create 29 | e2.id.should eq(2) 30 | e3 = EmptyColumn.find(2) 31 | s0 = e0.updated_at.as(Time) - e0.created_at.as(Time) 32 | s1 = e3.created_at.as(Time) - e0.created_at.as(Time) 33 | (s0.seconds >= 1).should be_truthy 34 | (s1.seconds >= 1).should be_truthy 35 | end 36 | 37 | it "Check all types" do 38 | AllTypes.drop_table 39 | AllTypes.create_table 40 | AllTypes.create("test", 10, 12.0f32, 10.0, Time.now) 41 | AllTypes.select.size.should eq(1) 42 | end 43 | 44 | it "Check Time" do 45 | AllTypes.drop_table 46 | AllTypes.create_table 47 | times = [Time.now - 10, Time.now] 48 | AllTypes.create("test", 10, 12.0f32, 10.0, times[0]) 49 | AllTypes.create("test", 10, 12.0f32, 10.0, times[1]) 50 | AllTypes.select.zip(times).each { |m, time| m.type_time == time } 51 | end 52 | 53 | it "Creates Int64 id" do 54 | IdInt64.drop_table 55 | IdInt64.create_table 56 | IdInt64.create 57 | IdInt64.select.size.should eq(1) 58 | IdInt64.select.first.id.class.to_s.should eq "Int64" 59 | end 60 | 61 | it "Search models" do 62 | SearchedModel.drop_table 63 | SearchedModel.create_table 64 | 10.times do |i| 65 | SearchedModel.create("mock#{i}", i) 66 | end 67 | SearchedModel.find(1).name.should eq("mock0") 68 | SearchedModel.where("name = 'mock0'").select.size.should eq(1) 69 | SearchedModel.order("age", "desc").range(1, 3).select.first.name.should eq("mock8") 70 | end 71 | 72 | it "Update models" do 73 | UpdatedModel.drop_table 74 | UpdatedModel.create_table 75 | 10.times do |i| 76 | up = UpdatedModel.create("mock#{i}", i) 77 | up.id.should eq(i+1) 78 | up.name.should eq("mock#{i}") 79 | end 80 | m = UpdatedModel.find(1) 81 | m.name = "mock_updated" 82 | m.update 83 | UpdatedModel.find(1).name.should eq("mock_updated") 84 | UpdatedModel.find(2).name.should eq("mock1") 85 | m2 = UpdatedModel.find(2) 86 | m2.update(name: "mock_updated2") 87 | m2.name.should eq("mock_updated2") 88 | UpdatedModel.find(2).name.should eq("mock_updated2") 89 | UpdatedModel.update(name: "mock_udpated_all") 90 | UpdatedModel.where("name = 'mock_udpated_all'").select.size.should eq(10) 91 | end 92 | 93 | it "Delete models" do 94 | DeletedModel.drop_table 95 | DeletedModel.create_table 96 | 10.times do |i| 97 | DeletedModel.create("mock#{i}", i) 98 | end 99 | DeletedModel.find(1).delete 100 | DeletedModel.select.size.should eq(9) 101 | DeletedModel.delete 102 | DeletedModel.select.size.should eq(0) 103 | end 104 | 105 | it "Nullable column" do 106 | NullableModel.drop_table 107 | NullableModel.create_table 108 | 109 | NullableModel.new("ok0", 12, 12.0).save 110 | NullableModel.create("ok1", 12, 12.0) 111 | NullableModel.create("ok2", 12, nil) 112 | 113 | n0 = NullableModel.find(1) 114 | n0.test0.should eq("ok0") 115 | n0.test1.should eq(12) 116 | n0.test2.should eq(12.0) 117 | 118 | n1 = NullableModel.find(2) 119 | n1.test0.should eq("ok1") 120 | n1.test1.should eq(12) 121 | n1.test2.should eq(12.0) 122 | 123 | n0.update(test2: nil) 124 | n2 = NullableModel.find(1) 125 | n2.test2.should eq(nil) 126 | 127 | n3 = NullableModel.find(2) 128 | n3.test2 = nil 129 | n3.update 130 | 131 | n4 = NullableModel.find(2) 132 | n4.test2.should eq(nil) 133 | 134 | NullableModel.create("ok2", 13, nil) 135 | n5 = NullableModel.find(3) 136 | n5.test2.should eq(nil) 137 | end 138 | 139 | it "Default column" do 140 | DefaultModel.drop_table 141 | DefaultModel.create_table 142 | 143 | DefaultModel.create("ok0", "ok1", "ok2", "ok3") # 1 144 | DefaultModel.create("ok0", "ok1", "ok2") # 2 145 | DefaultModel.create("ok0", "ok1", nil) # 3 146 | DefaultModel.create("ok0", "ok1") # 4 147 | DefaultModel.create("ok0") # 5 148 | 149 | d0 = DefaultModel.find(1) 150 | d1 = DefaultModel.find(2) 151 | d2 = DefaultModel.find(3) 152 | d3 = DefaultModel.find(4) 153 | d4 = DefaultModel.find(5) 154 | 155 | d0.test0.should eq "ok0" 156 | d0.test1.should eq "ok1" 157 | d0.test2.should eq "ok2" 158 | d0.test3.should eq "ok3" 159 | 160 | d1.test0.should eq "ok0" 161 | d1.test1.should eq "ok1" 162 | d1.test2.should eq "ok2" 163 | d1.test3.should eq nil 164 | 165 | d2.test0.should eq "ok0" 166 | d2.test1.should eq "ok1" 167 | d2.test2.should eq nil 168 | d2.test3.should eq nil 169 | 170 | d3.test0.should eq "ok0" 171 | d3.test1.should eq "ok1" 172 | d3.test2.should eq "OK2" 173 | d3.test3.should eq nil 174 | 175 | d4.test0.should eq "ok0" 176 | d4.test1.should eq "OK1" 177 | d4.test2.should eq "OK2" 178 | d4.test3.should eq nil 179 | end 180 | 181 | it "json" do 182 | 183 | JsonParent.drop_table 184 | JsonChild.drop_table 185 | 186 | JsonParent.create_table 187 | JsonChild.create_table 188 | 189 | p = JsonParent.create("John") 190 | c1 = JsonChild.create(12, p.id) 191 | c2 = JsonChild.create(15, p.id) 192 | c3 = JsonChild.create(23, p.id) 193 | 194 | time_p = p.created_at.as(Time).to_s("%FT%T%z") 195 | time_c1 = c1.created_at.as(Time).to_s("%FT%T%z") 196 | time_c2 = c2.created_at.as(Time).to_s("%FT%T%z") 197 | time_c3 = c3.created_at.as(Time).to_s("%FT%T%z") 198 | 199 | p.to_json.should eq("{\"id\":1,\"name\":\"John\",\"created_at\":\"#{time_p}\",\"updated_at\":\"#{time_p}\"}") 200 | c1.to_json.should eq("{\"id\":1,\"age\":12,\"p_id\":1,\"created_at\":\"#{time_c1}\",\"updated_at\":\"#{time_c1}\"}") 201 | c2.to_json.should eq("{\"id\":2,\"age\":15,\"p_id\":1,\"created_at\":\"#{time_c2}\",\"updated_at\":\"#{time_c2}\"}") 202 | c3.to_json.should eq("{\"id\":3,\"age\":23,\"p_id\":1,\"created_at\":\"#{time_c3}\",\"updated_at\":\"#{time_c3}\"}") 203 | 204 | p = JsonParent.from_json("{\"id\": -1, \"name\": \"Who\"}") 205 | p.save 206 | p.id.should eq(2) 207 | end 208 | 209 | it "Create in transaction" do 210 | TransactionModel.drop_table 211 | TransactionModel.create_table 212 | 213 | Topaz::Db.shared.transaction do |tx| 214 | TransactionModel.in(tx).select.size.should eq(0) 215 | t0 = TransactionModel.in(tx).create("name0") 216 | t1 = TransactionModel.new("name1").in(tx).save 217 | t0.name.should eq("name0") 218 | t1.name.should eq("name1") 219 | TransactionModel.in(tx).select.size.should eq(2) 220 | end 221 | 222 | TransactionModel.select.size.should eq(2) 223 | end 224 | 225 | it "Update in transaction" do 226 | TransactionModel.drop_table 227 | TransactionModel.create_table 228 | 229 | Topaz::Db.shared.transaction do |tx| 230 | 5.times do |i| 231 | TransactionModel.in(tx).create("name#{i}") 232 | end 233 | t0 = TransactionModel.in(tx).find(1) 234 | t0.name.should eq("name0") 235 | t0.name = "name0 updated" 236 | t0.in(tx).update 237 | TransactionModel.in(tx).find(1).name.should eq("name0 updated") 238 | TransactionModel.in(tx).find(2).name.should eq("name1") 239 | TransactionModel.in(tx).update(name: "all updated") 240 | TransactionModel.in(tx).select.each do |tm| 241 | tm.name.should eq("all updated") 242 | end 243 | end 244 | end 245 | 246 | it "Delete in transaction" do 247 | TransactionModel.drop_table 248 | TransactionModel.create_table 249 | 250 | Topaz::Db.shared.transaction do |tx| 251 | 5.times do |i| 252 | TransactionModel.in(tx).create("name#{i}") 253 | end 254 | TransactionModel.in(tx).select.size.should eq(5) 255 | t0 = TransactionModel.in(tx).find(1) 256 | t0.in(tx).delete 257 | TransactionModel.in(tx).select.size.should eq(4) 258 | TransactionModel.in(tx).delete 259 | TransactionModel.in(tx).select.size.should eq(0) 260 | end 261 | end 262 | 263 | describe "Persistence Check" do 264 | PersistenceModel.drop_table 265 | PersistenceModel.create_table 266 | 267 | describe "New Models" do 268 | n = PersistenceModel.new("val") 269 | 270 | it "Are Not Persisted" do 271 | n.new_record?.should be_truthy 272 | n.destroyed?.should be_falsey 273 | n.persisted?.should be_falsey 274 | end 275 | 276 | it "Saved Models Are Persisted" do 277 | n.save 278 | n.new_record?.should be_falsey 279 | n.destroyed?.should be_falsey 280 | n.persisted?.should be_truthy 281 | end 282 | end 283 | 284 | it "Create Values Are Persisted" do 285 | c = PersistenceModel.create("val") 286 | c.new_record?.should be_falsey 287 | c.destroyed?.should be_falsey 288 | c.persisted?.should be_truthy 289 | end 290 | 291 | it "Existing Records Are Persisted" do 292 | id = PersistenceModel.create("example").id 293 | e = PersistenceModel.find(id) 294 | e.new_record?.should be_falsey 295 | e.destroyed?.should be_falsey 296 | e.persisted?.should be_truthy 297 | end 298 | 299 | it "Destroyed Records Are No Longer Persisted" do 300 | id = PersistenceModel.create("example").id 301 | r = PersistenceModel.find(id) 302 | r.delete 303 | r.new_record?.should be_falsey 304 | r.destroyed?.should be_truthy 305 | r.persisted?.should be_falsey 306 | end 307 | end 308 | end 309 | end 310 | -------------------------------------------------------------------------------- /spec/model/sqlite3.cr: -------------------------------------------------------------------------------- 1 | require "sqlite3" 2 | require "./specs_for_models" 3 | select_db("sqlite3://./db/sample.db") 4 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/topaz" 3 | -------------------------------------------------------------------------------- /src/topaz.cr: -------------------------------------------------------------------------------- 1 | require "./topaz/*" 2 | 3 | # Topaz 4 | # Simple and useful db wrapper inspired by active record design pattern 5 | module Topaz 6 | end 7 | -------------------------------------------------------------------------------- /src/topaz/db.cr: -------------------------------------------------------------------------------- 1 | require "singleton" 2 | require "db" 3 | 4 | module Topaz 5 | class Db 6 | @@shared : DB::Database? 7 | 8 | # Setup a database by connection uri 9 | # See official sample for detail 10 | # For MySQL https://github.com/crystal-lang/crystal-mysql 11 | # For SQLite3 https://github.com/crystal-lang/crystal-sqlite3 12 | # For PostgreSQL https://github.com/will/crystal-pg 13 | def self.setup(connection : String) 14 | setup(URI.parse(connection)) 15 | end 16 | 17 | # Setup a database by connection uri 18 | def self.setup(uri : URI) 19 | @@shared = DB.open(uri) 20 | end 21 | 22 | # Get shared db instance 23 | def self.shared 24 | check 25 | @@shared.as(DB::Database) 26 | end 27 | 28 | # Close the database 29 | def self.close 30 | check 31 | @@shared.as(DB::Database).close 32 | @@shared = nil 33 | end 34 | 35 | def self.show_query(set : Bool) 36 | Topaz::Log.show_query(set) 37 | end 38 | 39 | def self.scheme 40 | check 41 | @@shared.as(DB::Database).uri.scheme 42 | end 43 | 44 | # Return time_format for each platform 45 | def self.time_format : String 46 | if instance = @@shared 47 | case instance.uri.scheme 48 | when "mysql", "sqlite3" 49 | return "%F %T:%L %z" 50 | when "postgres" 51 | return "%F %T.%L %z" 52 | end 53 | end 54 | "" 55 | end 56 | 57 | protected def self.check 58 | raise "Database is not initialized, please call Topaz::Db.setup(String|URI)" if @@shared.nil? 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /src/topaz/logger.cr: -------------------------------------------------------------------------------- 1 | require "logger" 2 | require "singleton" 3 | 4 | module Topaz 5 | class Log < SingleTon 6 | st_fields( 7 | {st_type: :property, name: debug_mode, type: Bool, df: false}, 8 | {st_type: :property, name: show_query, type: Bool, df: false}, 9 | {st_type: :property, name: log, type: Logger, df: Logger.new(STDOUT)} 10 | ) 11 | 12 | def self.debug_mode(set : Bool) 13 | log = Topaz::Log.get_instance 14 | log.debug_mode = set 15 | end 16 | 17 | protected def self.show_query(set : Bool) 18 | log = Topaz::Log.get_instance 19 | log.show_query = set 20 | end 21 | 22 | def self.d(msg : String) 23 | log = Topaz::Log.get_instance 24 | log.d(msg) 25 | end 26 | 27 | def d(msg : String) 28 | @log.level = Logger::Severity::DEBUG if @debug_mode && @log.level != Logger::Severity::DEBUG 29 | @log.debug("\e[36m[Topaz] #{msg}\e[m") if @debug_mode 30 | end 31 | 32 | def self.i(msg : String) 33 | log = Topaz::Log.get_instance 34 | log.i(msg) 35 | end 36 | 37 | def i(msg : String) 38 | @log.info("\e[36m[Topaz] #{msg}\e[m") 39 | end 40 | 41 | def self.e(msg : String) 42 | log = Topaz::Log.get_instance 43 | log.e(msg) 44 | end 45 | 46 | def e(msg : String) 47 | @log.error("\e[31m[Topaz -- Error ] #{msg}\e[m") 48 | end 49 | 50 | def self.f(msg : String) 51 | log = Topaz::Log.get_instance 52 | log.f(msg) 53 | end 54 | 55 | def f(msg : String) 56 | @log.fatal("\e[31m[Topaz -- Error(Fatal) ] #{msg}\e[m") 57 | end 58 | 59 | def self.w(msg : String) 60 | log = Topaz::Log.get_instance 61 | log.w(msg) 62 | end 63 | 64 | def w(msg : String) 65 | @log.warn("\e[33m[Topaz -- Warning] #{msg}\e[m") if @debug_mode 66 | end 67 | 68 | def self.q(msg : String, tx = nil) 69 | log = Topaz::Log.get_instance 70 | log.q(msg, tx) 71 | end 72 | 73 | def q(msg : String, tx = nil) 74 | @log.level = Logger::Severity::DEBUG if @show_query && @log.level != Logger::Severity::DEBUG 75 | @log.debug("\e[33m[Topaz query] #{msg}\e[m") if @show_query && tx.nil? 76 | @log.debug("\e[33m[Topaz query] #{msg} | In transaction\e[m") if @show_query && !tx.nil? 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /src/topaz/model.cr: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | # This is a main wrapper class for Topaz models. 4 | # Any class extending Topaz::Model can be transparent models for any databases. 5 | # The model have to call `columns` macro even if you don't have any columns 6 | # since the calling contruct every necessary functions 7 | module Topaz 8 | class Model 9 | @q : String? 10 | @tx : DB::Transaction? 11 | 12 | macro columns(_cols) 13 | {% id_type = _cols[:id] ? _cols[:id] : Int32 %} 14 | 15 | {% cols = {} of KeyType => ValueType %} 16 | {% for key, value in _cols %} 17 | {% cols[key] = value if key != :id %} 18 | {% end %} 19 | 20 | @id : {{ id_type }} = {{ id_type }}.new(-1) # Int32.new(-1) | Int64.new(-1) 21 | 22 | JSON.mapping( 23 | id: {{ id_type }}, 24 | {% for key, value in cols %} 25 | {% if value.is_a?(NamedTupleLiteral) %} 26 | {{key.id}}: {{value[:type]}}?, 27 | {% else %} 28 | {{key.id}}: {{value.id}}?, 29 | {% end %} 30 | {% end %} 31 | created_at: Time?, 32 | updated_at: Time?) 33 | 34 | def initialize({% for key, value in cols %} 35 | {% if value.is_a?(NamedTupleLiteral) %} 36 | {% if value[:nullable] %} 37 | {% if value[:default] %} 38 | @{{key.id}} : {{value[:type]}}? = {{value[:default]}}, 39 | {% else %} 40 | @{{key.id}} : {{value[:type]}}? = nil, 41 | {% end %} 42 | {% else %} 43 | {% if value[:default] %} 44 | @{{key.id}} : {{value[:type]}} = {{value[:default]}}, 45 | {% else %} 46 | @{{key.id}} : {{value[:type]}}, 47 | {% end %} 48 | {% end %} 49 | {% else %} 50 | @{{key.id}} : {{value.id}}, 51 | {% end %} 52 | {% end %}) 53 | end 54 | 55 | protected def initialize(@id : {{ id_type }}, 56 | {% for key, value in cols %} 57 | {% if value.is_a?(NamedTupleLiteral) %} 58 | @{{key.id}} : {{value[:type]}}?, 59 | {% else %} 60 | @{{key.id}} : {{value.id}}?, 61 | {% end %} 62 | {% end %}@created_at : Time, @updated_at : Time) 63 | end 64 | 65 | protected def initialize 66 | {% for key, value in cols %} 67 | @{{key.id}} = nil 68 | {% end %} 69 | end 70 | 71 | protected def set_query(q) 72 | @q = q 73 | self 74 | end 75 | 76 | def self.in(tx : DB::Transaction) 77 | new.in(tx) 78 | end 79 | 80 | def self.find(id) 81 | new.set_query("where id = #{id}").select.first 82 | end 83 | 84 | def self.where(q : String) 85 | new.set_query("where #{q} ") 86 | end 87 | 88 | def self.order(column : String, sort = "asc") 89 | new.set_query("order by #{column} #{sort} ") 90 | end 91 | 92 | def self.range(offset : Int, limit : Int) 93 | new.set_query("limit #{limit} offset #{offset} ") 94 | end 95 | 96 | def self.select 97 | new.select 98 | end 99 | 100 | def self.update(**data) 101 | new.update(**data) 102 | end 103 | 104 | def self.delete 105 | new.delete 106 | end 107 | 108 | def in(tx : DB::Transaction) 109 | @tx = tx 110 | self 111 | end 112 | 113 | def find(id) 114 | model = typeof(self).new 115 | model.in(@tx.as(DB::Transaction)) unless @tx.nil? 116 | model.set_query("where id = #{id}").select.first 117 | end 118 | 119 | def where(q : String) 120 | @q = "#{@q}where #{q} " 121 | self 122 | end 123 | 124 | def and(q : String) 125 | @q = "#{@q}and #{q} " 126 | self 127 | end 128 | 129 | def or(q : String) 130 | @q = "#{@q}or #{q} " 131 | self 132 | end 133 | 134 | def order(column : String, sort = "asc") 135 | @q = "#{@q}order by #{column} #{sort} " 136 | self 137 | end 138 | 139 | def range(offset : Int, limit : Int) 140 | @q = "#{@q}limit #{limit} offset #{offset} " 141 | self 142 | end 143 | 144 | def delete 145 | @q = "where id = #{@id}" unless new_record? 146 | @q = "delete from #{table_name} #{@q}" 147 | exec 148 | @destroyed = true 149 | refresh 150 | end 151 | 152 | def destroyed? 153 | @destroyed ||= false 154 | end 155 | 156 | def new_record? 157 | @id == -1 158 | end 159 | 160 | def persisted? 161 | !(new_record? || destroyed?) 162 | end 163 | 164 | def update(**data) 165 | 166 | @q = "where id = #{@id}" unless new_record? 167 | 168 | updated = "" 169 | 170 | time = Time.now 171 | 172 | if data.keys.size == 0 173 | {% for key, value, idx in cols %} 174 | {% if value.is_a?(NamedTupleLiteral) %} 175 | {% if value[:nullable] %} 176 | updated += "{{key.id}} = #{to_db_value(@{{key.id}}, true)}, " unless @{{key.id}}.nil? 177 | updated += "{{key.id}} = null, " if @{{key.id}}.nil? 178 | {% else %} 179 | updated += "{{key.id}} = #{to_db_value(@{{key.id}})}, " unless @{{key.id}}.nil? 180 | {% end %} 181 | {% else %} 182 | updated += "{{key.id}} = #{to_db_value(@{{key.id}})}, " unless @{{key.id}}.nil? 183 | {% end %} 184 | {% end %} 185 | else 186 | data.each_with_index do |key, value, idx| 187 | unless value.nil? 188 | updated += "#{key} = #{to_db_value(value)}, " 189 | set_value_of(key.to_s, value) unless new_record? 190 | else 191 | updated += "#{key} = null, " 192 | set_value_of(key.to_s, value) unless new_record? 193 | end 194 | end 195 | end 196 | 197 | updated += "updated_at = \'#{time.to_s(Topaz::Db.time_format)}\'" 198 | @updated_at = time 199 | @q = "update #{table_name} set #{updated} #{@q}" 200 | exec 201 | refresh 202 | end 203 | 204 | protected def set_value_of(_key : String, _value : DB::Any) 205 | {% if cols.size > 0 %} 206 | case _key 207 | {% for key, value in cols %} 208 | when "{{key.id}}" 209 | {% if value.is_a?(NamedTupleLiteral) %} 210 | @{{key.id}} = _value if _value.is_a?({{value[:type]}}) 211 | {% else %} 212 | @{{key.id}} = _value if _value.is_a?({{value.id}}) 213 | {% end %} 214 | {% end %} 215 | end 216 | {% end %} 217 | end 218 | 219 | def select 220 | @q = "select * from #{table_name} #{@q}" 221 | Topaz::Log.q @q.as(String), @tx unless @q.nil? 222 | 223 | res = read_result(Topaz::Db.shared) if @tx.nil? 224 | res = read_result(@tx.as(DB::Transaction).connection) unless @tx.nil? 225 | 226 | raise "Failed to read data from database" if res.nil? 227 | 228 | refresh 229 | res.as(Set) 230 | end 231 | 232 | protected def read_value(rows, type : T.class) : T forall T 233 | if type == Time 234 | Time.parse(rows.read(String), Topaz::Db.time_format) 235 | elsif type == Time? 236 | if val = rows.read(String?) 237 | Time.parse(val, Topaz::Db.time_format) 238 | end 239 | else 240 | rows.read(type) 241 | end.as(T) 242 | end 243 | 244 | protected def read_result(db : DB::Database|DB::Connection) 245 | 246 | set = Set.new 247 | 248 | db.query(@q.as(String)) do |rows| 249 | rows.each do 250 | case Topaz::Db.scheme 251 | when "mysql", "postgres" 252 | set.push( 253 | typeof(self).new( 254 | rows.read({{ id_type.id }}), # id 255 | {% for key, value in cols %} 256 | {% if value.is_a?(NamedTupleLiteral) %} 257 | read_value(rows, {{value[:type]}}?), 258 | {% else %} 259 | read_value(rows, {{value.id}}?), 260 | {% end %} 261 | {% end %} 262 | read_value(rows, Time), 263 | read_value(rows, Time) 264 | )) 265 | when "sqlite3" 266 | set.push( 267 | typeof(self).new( 268 | {{ id_type }}.new(rows.read(Int64)), # id 269 | {% for key, value in cols %} 270 | {% if value.is_a?(NamedTupleLiteral) %} 271 | {% if value[:type].id == "Int32" %} 272 | (rows.read(Int64?) || Nilwrapper).to_i32, 273 | {% elsif value[:type].id == "Float32" %} 274 | (rows.read(Float64?) || Nilwrapper).to_f32, 275 | {% else %} 276 | read_value(rows, {{value[:type]}}?), 277 | {% end %} 278 | {% else %} 279 | {% if value.id == "Int32" %} 280 | (rows.read(Int64?) || Nilwrapper).to_i32, 281 | {% elsif value.id == "Float32" %} 282 | (rows.read(Float64?) || Nilwrapper).to_f32, 283 | {% else %} 284 | read_value(rows, {{value.id}}?), 285 | {% end %} 286 | {% end %} 287 | {% end %} 288 | read_value(rows, Time), 289 | read_value(rows, Time) 290 | )) 291 | end 292 | end 293 | end unless @q.nil? 294 | set 295 | end 296 | 297 | def self.create( 298 | {% for key, value in cols %} 299 | {% if value.is_a?(NamedTupleLiteral) %} 300 | {% if value[:nullable] %} 301 | {% if value[:default] %} 302 | {{key.id}} : {{value[:type]}}? = {{value[:default]}}, 303 | {% else %} 304 | {{key.id}} : {{value[:type]}}? = nil, 305 | {% end %} 306 | {% else %} 307 | {% if value[:default] %} 308 | {{key.id}} : {{value[:type]}} = {{value[:default]}}, 309 | {% else %} 310 | {{key.id}} : {{value[:type]}}, 311 | {% end %} 312 | {% end %} 313 | {% else %} 314 | {{key.id}} : {{value.id}}, 315 | {% end %} 316 | {% end %} 317 | ) 318 | model = new({% for key, value in cols %}{{key.id}},{% end %}) 319 | model.save 320 | model 321 | end 322 | 323 | def create( 324 | {% for key, value in cols %} 325 | {% if value.is_a?(NamedTupleLiteral) %} 326 | {% if value[:nullable] %} 327 | {% if value[:default] %} 328 | {{key.id}} : {{value[:type]}}? = {{value[:default]}}, 329 | {% else %} 330 | {{key.id}} : {{value[:type]}}? = nil, 331 | {% end %} 332 | {% else %} 333 | {% if value[:default] %} 334 | {{key.id}} : {{value[:type]}} = {{value[:default]}}, 335 | {% else %} 336 | {{key.id}} : {{value[:type]}}, 337 | {% end %} 338 | {% end %} 339 | {% else %} 340 | {{key.id}} : {{value.id}}, 341 | {% end %} 342 | {% end %} 343 | ) 344 | model = typeof(self).new({% for key, value in cols %}{{key.id}},{% end %}) 345 | model.in(@tx.as(DB::Transaction)) unless @tx.nil? 346 | model.save 347 | model 348 | end 349 | 350 | private def to_db_value(val, nullable = false) : String 351 | case val 352 | when Time then "'#{val.to_s(Topaz::Db.time_format)}'" 353 | else "'#{val}'" 354 | end 355 | end 356 | 357 | def save 358 | keys = [] of String 359 | vals = [] of String 360 | 361 | {% for key, value in cols %} 362 | {% if value.is_a?(NamedTupleLiteral) %} 363 | {% if value[:nullable] %} 364 | keys.push("{{key.id}}") 365 | vals.push(to_db_value(@{{key.id}}, nullable: true)) unless @{{key.id}}.nil? 366 | vals.push("null") if @{{key.id}}.nil? 367 | {% else %} 368 | keys.push("{{key.id}}") unless @{{key.id}}.nil? 369 | vals.push(to_db_value(@{{key.id}})) unless @{{key.id}}.nil? 370 | {% end %} 371 | {% else %} 372 | keys.push("{{key.id}}") unless @{{key.id}}.nil? 373 | vals.push(to_db_value(@{{key.id}})) unless @{{key.id}}.nil? 374 | {% end %} 375 | {% end %} 376 | 377 | time = Time.now 378 | 379 | keys.push("created_at") 380 | keys.push("updated_at") 381 | vals.push("\'#{time.to_s(Topaz::Db.time_format)}\'") 382 | vals.push("\'#{time.to_s(Topaz::Db.time_format)}\'") 383 | 384 | _keys = keys.join(", ") 385 | _vals = vals.join(", ") 386 | 387 | @q = "insert into #{table_name}(#{_keys}) values(#{_vals})" 388 | 389 | res = exec 390 | 391 | # Note: Postgres doesn't support this 392 | if new_record? && Topaz::Db.scheme == "postgres" 393 | @id = find_id_for_postgres(Topaz::Db.shared) if @tx.nil? 394 | @id = find_id_for_postgres(@tx.as(DB::Transaction).connection) unless @tx.nil? 395 | else 396 | @id = {{ id_type }}.new(res.last_insert_id) 397 | end 398 | 399 | @created_at = time 400 | @updated_at = time 401 | 402 | refresh 403 | 404 | self 405 | end 406 | 407 | protected def find_id_for_postgres(db : DB::Database|DB::Connection) 408 | id : Int64 = -1i64 409 | db.query("select currval(\'#{table_name}_seq\')") do |rows| 410 | rows.each do 411 | id = rows.read(Int64) 412 | end 413 | end 414 | {{ id_type }}.new(id) 415 | end 416 | 417 | def to_a 418 | [ 419 | ["id", @id], 420 | {% for key, value in cols %}["{{key.id}}", @{{key.id}}],{% end %} 421 | ["created_at", "#{@created_at}"], 422 | ["updated_at", "#{@updated_at}"], 423 | ] 424 | end 425 | 426 | def to_h 427 | { 428 | "id" => @id, 429 | {% for key, value in cols %}"{{key.id}}" => @{{key.id}},{% end %} 430 | "created_at" => "#{@created_at}", 431 | "updated_at" => "#{@updated_at}", 432 | } 433 | end 434 | 435 | protected def self.registered_columns 436 | arr = Array(String).new 437 | arr.push("id") 438 | q = "" 439 | case Topaz::Db.scheme 440 | when "mysql" 441 | q = "show columns from #{table_name}" 442 | when "postgres" 443 | q = "select column_name, data_type from information_schema.columns where table_name = \'#{table_name}\'" 444 | when "sqlite3" 445 | q = "pragma table_info(\'#{table_name}\')" 446 | end 447 | Topaz::Db.shared.query(q) do |rows| 448 | rows.each do 449 | rows.read(Int32) if Topaz::Db.scheme == "sqlite3" 450 | name = rows.read(String) 451 | if name != "id" && name != "created_at" && name != "updated_at" 452 | arr.push(name) 453 | end 454 | end 455 | end 456 | arr.push("created_at") 457 | arr.push("updated_at") 458 | arr 459 | end 460 | 461 | protected def self.defined_columns 462 | arr = Array(String).new 463 | arr.push("id") 464 | {% for key, value in cols %} 465 | {% if value.is_a?(NamedTupleLiteral) %} 466 | arr.push("{{key.id}}") 467 | {% else %} 468 | arr.push("{{key.id}}") 469 | {% end %} 470 | {% end %} 471 | arr.push("created_at") 472 | arr.push("updated_at") 473 | arr 474 | end 475 | 476 | protected def self.copy_data_from_old 477 | 478 | copied_columns = Array(String).new 479 | defined = defined_columns 480 | 481 | registered_columns.each do |col| 482 | copied_columns.push(col) if defined.includes?(col) 483 | end 484 | 485 | copied = copied_columns.join(", ") 486 | "insert into #{table_name}(#{copied}) select #{copied} from #{table_name}_old" 487 | end 488 | 489 | def self.migrate_table 490 | copy_query = copy_data_from_old 491 | Topaz::Db.shared.transaction do |tx| 492 | tx.connection.exec "alter table #{table_name} rename to #{table_name}_old" 493 | tx.connection.exec create_table_query 494 | tx.connection.exec copy_query 495 | tx.connection.exec "drop table if exists #{table_name}_old" 496 | tx.commit 497 | end 498 | end 499 | 500 | def self.create_table_query 501 | q = "" 502 | case Topaz::Db.scheme 503 | when "mysql" 504 | q = <<-QUERY 505 | create table if not exists #{table_name}(id #{get_type({{ id_type }})} not null auto_increment, 506 | {% for key, value in cols %} 507 | {% if value.is_a?(NamedTupleLiteral) %} 508 | {{key.id}} #{get_type({{value[:type]}})} 509 | {% if value[:nullable] != nil && value[:nullable] %} 510 | null 511 | {% elsif value[:nullable] != nil && !value[:nullable] %} 512 | not null 513 | {% end %}, 514 | {% else %} 515 | {{key.id}} #{get_type({{value.id}})}, 516 | {% end %}{% end %} 517 | created_at varchar(64), 518 | updated_at varchar(64), 519 | index(id)); 520 | QUERY 521 | when "postgres" 522 | q = <<-QUERY 523 | create table if not exists #{table_name}(id #{get_type({{ id_type }})} default nextval(\'#{table_name}_seq\') not null 524 | {% for key, value in cols %} 525 | {% if value.is_a?(NamedTupleLiteral) %} 526 | ,{{key.id}} #{get_type({{value[:type]}})} 527 | {% if value[:nullable] != nil && value[:nullable] %} 528 | null 529 | {% elsif value[:nullable] != nil && !value[:nullable] %} 530 | not null 531 | {% end %} 532 | {% else %} 533 | ,{{key.id}} #{get_type({{value.id}})} 534 | {% end %}{% end %} 535 | ,created_at varchar(64) 536 | ,updated_at varchar(64)); 537 | QUERY 538 | when "sqlite3" 539 | q = <<-QUERY 540 | create table if not exists #{table_name}(id #{get_type({{ id_type }})} primary key 541 | {% for key, value in cols %} 542 | {% if value.is_a?(NamedTupleLiteral) %} 543 | ,{{key.id}} #{get_type({{value[:type]}})} 544 | {% if value[:nullable] != nil && value[:nullable] %} 545 | null 546 | {% elsif value[:nullable] != nil && !value[:nullable] %} 547 | not null 548 | {% end %} 549 | {% else %} 550 | ,{{key.id}} #{get_type({{value.id}})} 551 | {% end %}{% end %} 552 | ,created_at varchar(64) 553 | ,updated_at varchar(64)); 554 | QUERY 555 | end 556 | 557 | q.gsub("\n", "") 558 | end 559 | 560 | def self.create_table 561 | exec "create sequence #{table_name}_seq start 1" if Topaz::Db.scheme == "postgres" 562 | exec create_table_query 563 | end 564 | 565 | def self.drop_table 566 | exec "drop table if exists #{table_name}" 567 | exec "drop sequence if exists #{table_name}_seq" if Topaz::Db.scheme == "postgres" 568 | end 569 | 570 | protected def self.exec(q) 571 | new.set_query(q).exec 572 | end 573 | 574 | protected def exec 575 | Topaz::Log.q @q.as(String), @tx unless @q.nil? 576 | res = Topaz::Db.shared.exec @q.as(String) if @tx.nil? && !@q.nil? 577 | res = @tx.as(DB::Transaction).connection.exec @q.as(String) unless @tx.nil? && !@q.nil? 578 | raise "Failed to execute \'#{@q}\'" if res.nil? 579 | res.as(DB::ExecResult) 580 | end 581 | 582 | protected def self.downcase 583 | class_name = self.to_s.gsub("::", '_') 584 | class_name = class_name.gsub(/[A-Z]/){ |a| '_' + a.downcase } 585 | class_name = class_name[1..class_name.size-1] if class_name.starts_with?('_') 586 | class_name 587 | end 588 | 589 | def self.table_name 590 | downcase 591 | end 592 | 593 | def table_name 594 | typeof(self).downcase 595 | end 596 | 597 | protected def refresh 598 | @q = "" 599 | @tx = nil 600 | end 601 | 602 | protected def self.get_type(t) 603 | case t.to_s 604 | when "String" 605 | return "text" 606 | when "Int32" 607 | return "int" if Topaz::Db.scheme == "mysql" 608 | return "integer" if Topaz::Db.scheme == "postgres" 609 | return "integer" if Topaz::Db.scheme == "sqlite3" 610 | when "Int64" 611 | return "bigint" if Topaz::Db.scheme == "mysql" || Topaz::Db.scheme == "postgres" 612 | return "integer" if Topaz::Db.scheme == "sqlite3" 613 | when "Float32" 614 | return "float" if Topaz::Db.scheme == "mysql" || Topaz::Db.scheme == "sqlite3" 615 | return "real" if Topaz::Db.scheme == "postgres" 616 | when "Float64" 617 | return "double" if Topaz::Db.scheme == "mysql" || Topaz::Db.scheme == "sqlite3" 618 | return "double precision" if Topaz::Db.scheme == "postgres" 619 | when "Bool" 620 | return "tinyint" 621 | when "Time" 622 | return "varchar(64)" 623 | end 624 | end 625 | 626 | class Set < Array(self) 627 | # Model set 628 | def to_json 629 | "[#{map(&.to_json).join(", ")}]" 630 | end 631 | end 632 | 633 | {% for key, value in cols %} 634 | {% if value.is_a?(NamedTupleLiteral) %} 635 | {% if value[:nullable] %} 636 | def {{key.id}}=(@{{key.id}} : {{value[:type]}}?) 637 | end 638 | def {{key.id}} : {{value[:type]}}? 639 | return @{{key.id}}.as({{value[:type]}}?) 640 | end 641 | {% else %} 642 | def {{key.id}}=(@{{key.id}} : {{value[:type]}}) 643 | end 644 | def {{key.id}} : {{value[:type]}} 645 | return @{{key.id}}.as({{value[:type]}}) 646 | end 647 | {% end %} 648 | {% else %} 649 | def {{key.id}}=(@{{key.id}} : {{value.id}}) 650 | end 651 | def {{key.id}} : {{value.id}} 652 | return @{{key.id}}.as({{value.id}}) 653 | end 654 | {% end %} 655 | {% end %} 656 | end 657 | 658 | macro columns(**cols) 659 | {% if cols.size > 0 %} 660 | columns({{cols}}) 661 | {% else %} 662 | columns({} of Symbol => String) 663 | {% end %} 664 | end 665 | 666 | macro has_many(models) 667 | {% for key, value in models %} 668 | def {{key.id}} 669 | {{value[:model].id}}.where("{{value[:key].id}} = #{@id}").select 670 | end 671 | {% end %} 672 | 673 | def elements(ms : Symbol|String) 674 | {% if models.size > 0 %} 675 | case ms 676 | {% for key, value in models %} 677 | when :{{key.id}}, "{{key.id}}" 678 | return {{key.id}} 679 | {% end %} 680 | end 681 | {% end %} 682 | end 683 | end 684 | 685 | macro has_many(**models) 686 | has_many({{models}}) 687 | end 688 | 689 | macro belongs_to(models) 690 | {% for key, value in models %} 691 | def {{key.id}} 692 | {{value[:model].id}}.find({{value[:key].id}}) 693 | end 694 | {% end %} 695 | end 696 | 697 | macro belongs_to(**models) 698 | belongs_to({{models}}) 699 | end 700 | end 701 | end 702 | -------------------------------------------------------------------------------- /src/topaz/nilwrapper.cr: -------------------------------------------------------------------------------- 1 | # In SQLite3, all data is saved as 64 bits. 2 | # So we need to convert it into 32 bits variables. 3 | # See model.cr to know how to use this. 4 | class Nilwrapper 5 | def self.to_i32 6 | nil 7 | end 8 | 9 | def self.to_f32 10 | nil 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /src/topaz/version.cr: -------------------------------------------------------------------------------- 1 | module Topaz 2 | VERSION = "0.2.7" 3 | end 4 | --------------------------------------------------------------------------------