├── .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 [](https://travis-ci.org/topaz-crystal/topaz) []()
4 | [](https://shards.rocks/github/topaz-crystal/topaz)
5 | [](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 |
--------------------------------------------------------------------------------