Repository: amberframework/granite Branch: master Commit: f4711b63bacf Files: 132 Total size: 282.1 KB Directory structure: gitextract_almbvcqk/ ├── .dockerignore ├── .envrc ├── .github/ │ └── workflows/ │ └── spec.yml ├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── README.md ├── docker/ │ ├── docker-compose.mysql.yml │ ├── docker-compose.pg.yml │ └── docker-compose.sqlite.yml ├── docker-compose.yml ├── docs/ │ ├── callbacks.md │ ├── crud.md │ ├── imports.md │ ├── migrations.md │ ├── models.md │ ├── multiple_connections.md │ ├── querying.md │ ├── readme.md │ ├── relationships.md │ └── validations.md ├── export.sh ├── shard.yml ├── spec/ │ ├── adapter/ │ │ ├── adapters_spec.cr │ │ ├── mysql_spec.cr │ │ ├── pg_spec.cr │ │ └── sqlite_spec.cr │ ├── granite/ │ │ ├── associations/ │ │ │ ├── belongs_to_spec.cr │ │ │ ├── has_many_spec.cr │ │ │ ├── has_many_through_spec.cr │ │ │ └── has_one_spec.cr │ │ ├── callbacks/ │ │ │ ├── abort_spec.cr │ │ │ └── callbacks_spec.cr │ │ ├── columns/ │ │ │ ├── primary_key_spec.cr │ │ │ ├── read_attribute_spec.cr │ │ │ ├── timestamps_spec.cr │ │ │ └── uuid_spec.cr │ │ ├── connection_management_spec.cr │ │ ├── converters/ │ │ │ ├── converters_spec.cr │ │ │ ├── enum_spec.cr │ │ │ ├── json_spec.cr │ │ │ └── pg_numeric_spec.cr │ │ ├── error/ │ │ │ └── error_spec.cr │ │ ├── exceptions/ │ │ │ ├── record_invalid_spec.cr │ │ │ └── record_not_destroyed_spec.cr │ │ ├── integrators/ │ │ │ └── find_or_spec.cr │ │ ├── migrator/ │ │ │ └── migrator_spec.cr │ │ ├── query/ │ │ │ ├── assemblers/ │ │ │ │ ├── mysql_spec.cr │ │ │ │ ├── pg_spec.cr │ │ │ │ └── sqlite_spec.cr │ │ │ ├── builder_spec.cr │ │ │ ├── executor_spec.cr │ │ │ └── spec_helper.cr │ │ ├── querying/ │ │ │ ├── all_spec.cr │ │ │ ├── count_spec.cr │ │ │ ├── exists_spec.cr │ │ │ ├── find_by_spec.cr │ │ │ ├── find_each_spec.cr │ │ │ ├── find_in_batches.cr │ │ │ ├── find_spec.cr │ │ │ ├── first_spec.cr │ │ │ ├── from_rs_spec.cr │ │ │ ├── passthrough_spec.cr │ │ │ ├── query_builder_spec.cr │ │ │ └── reload_spec.cr │ │ ├── select/ │ │ │ └── select_spec.cr │ │ ├── table/ │ │ │ └── table_spec.cr │ │ ├── transactions/ │ │ │ ├── create_spec.cr │ │ │ ├── destroy_spec.cr │ │ │ ├── import_spec.cr │ │ │ ├── save_natural_key_spec.cr │ │ │ ├── save_spec.cr │ │ │ ├── touch_spec.cr │ │ │ └── update_spec.cr │ │ ├── validation_helpers/ │ │ │ ├── blank_spec.cr │ │ │ ├── choice_spec.cr │ │ │ ├── exclusion_spec.cr │ │ │ ├── inequality_spec.cr │ │ │ ├── lenght_spec.cr │ │ │ ├── nil_spec.cr │ │ │ └── uniqueness_spec.cr │ │ └── validations/ │ │ └── validator_spec.cr │ ├── granite_spec.cr │ ├── mocks/ │ │ └── db_mock.cr │ ├── run_all_specs.sh │ ├── run_test_dbs.sh │ ├── spec_helper.cr │ └── spec_models.cr └── src/ ├── adapter/ │ ├── base.cr │ ├── mysql.cr │ ├── pg.cr │ └── sqlite.cr ├── granite/ │ ├── association_collection.cr │ ├── associations.cr │ ├── base.cr │ ├── callbacks.cr │ ├── collection.cr │ ├── columns.cr │ ├── connection_management.cr │ ├── connections.cr │ ├── converters.cr │ ├── error.cr │ ├── exceptions.cr │ ├── integrators.cr │ ├── migrator.cr │ ├── query/ │ │ ├── assemblers/ │ │ │ ├── base.cr │ │ │ ├── mysql.cr │ │ │ ├── pg.cr │ │ │ └── sqlite.cr │ │ ├── builder.cr │ │ ├── builder_methods.cr │ │ └── executors/ │ │ ├── base.cr │ │ ├── list.cr │ │ ├── multi_value.cr │ │ └── value.cr │ ├── querying.cr │ ├── select.cr │ ├── settings.cr │ ├── table.cr │ ├── transactions.cr │ ├── type.cr │ ├── validation_helpers/ │ │ ├── blank.cr │ │ ├── choice.cr │ │ ├── exclusion.cr │ │ ├── inequality.cr │ │ ├── length.cr │ │ ├── nil.cr │ │ └── uniqueness.cr │ ├── validators.cr │ └── version.cr └── granite.cr ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ bin ================================================ FILE: .envrc ================================================ dotenv_if_exists ================================================ FILE: .github/workflows/spec.yml ================================================ name: spec on: push: pull_request: branches: [main, master] # schedule: # - cron: "0 6 * * 6" # Every Saturday 6 AM jobs: formatting: runs-on: ubuntu-latest steps: - name: Download source uses: actions/checkout@v2 - name: Install Crystal uses: oprypin/install-crystal@v1.8.0 with: crystal: latest - name: Install Ameba run: shards install - name: Check formatting run: crystal tool format --check - name: Check linting run: ./bin/ameba sqlite-spec: runs-on: ubuntu-latest strategy: fail-fast: false max-parallel: 1 matrix: crystal: [1.6.2, 1.7.2, 1.8.1, latest] steps: - name: Download source uses: actions/checkout@v2 - name: Install Crystal uses: oprypin/install-crystal@v1.8.0 with: crystal: ${{ matrix.crystal }} - name: Install shards run: shards update --ignore-crystal-version --skip-postinstall --skip-executables - name: Run tests timeout-minutes: 2 run: crystal spec env: CURRENT_ADAPTER: sqlite SQLITE_DATABASE_URL: sqlite3:./granite.db SQLITE_REPLICA_URL: sqlite3:./granite_replica.db mysql-spec: runs-on: ubuntu-latest strategy: fail-fast: false max-parallel: 1 matrix: crystal: [1.6.2, 1.7.2, 1.8.1, latest] services: mysql: image: mysql:5.7 options: >- --health-cmd "mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 5 env: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: granite_db MYSQL_USER: granite MYSQL_PASSWORD: password ports: - 3306:3306 steps: - name: Download source uses: actions/checkout@v2 - name: Install Crystal uses: oprypin/install-crystal@v1.8.0 with: crystal: ${{ matrix.crystal }} - name: Install shards run: shards update --ignore-crystal-version --skip-postinstall --skip-executables - name: Run tests timeout-minutes: 2 run: crystal spec env: CURRENT_ADAPTER: mysql SQLITE_DATABASE_URL: sqlite3:./granite.db MYSQL_DATABASE_URL: mysql://granite:password@localhost:3306/granite_db MYSQL_REPLICA_URL: mysql://granite:password@localhost:3306/granite_db psql-spec: runs-on: ubuntu-latest strategy: fail-fast: false max-parallel: 1 matrix: crystal: [1.6.2, 1.7.2, 1.8.1, latest] services: postgres: image: postgres:15.2 env: POSTGRES_USER: granite POSTGRES_PASSWORD: password POSTGRES_DB: granite_db options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 ports: # Maps tcp port 5432 on service container to the host - 5432:5432 steps: - name: Download source uses: actions/checkout@v2 - name: Install Crystal uses: oprypin/install-crystal@v1.8.0 with: crystal: ${{ matrix.crystal }} - name: Install shards run: shards update --ignore-crystal-version --skip-postinstall --skip-executables - name: Run tests timeout-minutes: 2 run: crystal spec env: CURRENT_ADAPTER: pg PG_DATABASE_URL: postgres://granite:password@localhost:5432/granite_db PG_REPLICA_URL: postgres://granite:password@localhost:5432/granite_db ================================================ FILE: .gitignore ================================================ /lib/ /.shards/ /.deps/ /.crystal/ /doc/ *.db # Libraries don't need dependency lock # Dependencies will be locked in application that uses them /.deps.lock shard.lock # Ignore bin because they will be build with shards install bin .env ================================================ FILE: .travis.yml ================================================ language: generic services: - docker before_install: - docker-compose -f docker/docker-compose.$CURRENT_ADAPTER.yml build spec script: - docker-compose -f docker/docker-compose.$CURRENT_ADAPTER.yml run spec matrix: include: - name: "Mysql 5.7" env: - CURRENT_ADAPTER=mysql - MYSQL_VERSION=5.7 - PG_VERSION=10.5 - name: "Mysql 5.6" env: - CURRENT_ADAPTER=mysql - MYSQL_VERSION=5.6 - PG_VERSION=10.5 - name: "Postgres 9.6" env: - CURRENT_ADAPTER=pg - MYSQL_VERSION=5.7 - PG_VERSION=9.6 - name: "Postgres 10.5" env: - CURRENT_ADAPTER=pg - MYSQL_VERSION=5.7 - PG_VERSION=10.5 - name: "Postgres 11" env: - CURRENT_ADAPTER=pg - MYSQL_VERSION=5.7 - PG_VERSION=11 - name: "Sqlite 3.11.0" env: - CURRENT_ADAPTER=sqlite - MYSQL_VERSION=5.7 - PG_VERSION=10.5 - SQLITE_VERSION=3110000 - SQLITE_VERSION_YEAR=2016 - name: "Sqlite 3.25.2" env: - CURRENT_ADAPTER=sqlite - MYSQL_VERSION=5.7 - PG_VERSION=10.5 - SQLITE_VERSION=3250200 - SQLITE_VERSION_YEAR=2018 ================================================ FILE: Dockerfile ================================================ FROM 84codes/crystal:latest-ubuntu-jammy # Install deps RUN apt-get update -qq && apt-get install -y --no-install-recommends libpq-dev libmysqlclient-dev libsqlite3-dev WORKDIR /app/user COPY shard.yml /app/user COPY shard.lock /app/user RUN shards install COPY src /app/user/src COPY spec /app/user/spec ENTRYPOINT [] ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2019 dru.jensen Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Granite [Amber](https://github.com/amberframework/amber) is a web framework written in the [Crystal](https://github.com/crystal-lang/crystal) language. This project is to provide an ORM in Crystal. # Looking for maintainers Granite is looking for volunteers to take over maintainership of the repository, reviewing and merging pull requests, stewarding updates to follow along with Crystal language updates, etc. [More information here](https://github.com/amberframework/granite/issues/462) ## Documentation [Documentation](docs/readme.md) ## Contributing 1. Fork it ( https://github.com/amberframework/granite/fork ) 2. Create your feature branch (git checkout -b my-new-feature) 3. Commit your changes (git commit -am 'Add some feature') 4. Push to the branch (git push origin my-new-feature) 5. Create a new Pull Request ## Running tests Granite uses Crystal's built in test framework. The tests can be run either within a [dockerized testing environment](#docker-setup) or [locally](#local-setup). The test suite depends on access to a PostgreSQL, MySQL, and SQLite database to ensure the adapters work as intended. ### Docker setup There is a self-contained testing environment provided via the `docker-compose.yml` file in this repository. We are testing against multiple databases so you have to specify which docker-compose file you would like to use. - You can find postgres versions at https://hub.docker.com/_/postgres/ - You can find mysql versions at https://hub.docker.com/_/mysql/ After you have docker installed do the following to run tests: #### Environment variable setup ##### Option 1 Export `.env` with `$ source ./export.sh` or `$ source .env`. ##### Option 2 Modify the `.env` file that docker-compose loads by default. The `.env` file can either be copied to the same directory as the docker-compose.{database_type}.yml files or passed as an option to the docker-compose commands `--env-file ./foo/.env`. #### First run > Replace "{database_type}" with "mysql" or "pg" or "sqlite". ``` $ docker-compose -f docker/docker-compose.{database_type}.yml build spec $ docker-compose -f docker/docker-compose.{database_type}.yml run spec ``` #### Subsequent runs ``` $ docker-compose -f docker/docker-compose.{database_type}.yml run spec ``` #### Cleanup If you're done testing and you'd like to shut down and clean up the docker dependences run the following: ``` $ docker-compose -f docker/docker-compose.{database_type}.yml down ``` #### Run all To run the specs for each database adapter use `./spec/run_all_specs.sh`. This will build and run each adapter, then cleanup after itself. ### Local setup If you'd like to test without docker you can do so by following the instructions below: 1. Install dependencies with `$ shards install ` 2. Update .env to use appropriate ENV variables, or create appropriate databases. 3. Setup databases: #### PostgreSQL ```sql CREATE USER granite WITH PASSWORD 'password'; CREATE DATABASE granite_db; GRANT ALL PRIVILEGES ON DATABASE granite_db TO granite; ``` #### MySQL ```sql CREATE USER 'granite'@'localhost' IDENTIFIED BY 'password'; CREATE DATABASE granite_db; GRANT ALL PRIVILEGES ON granite_db.* TO 'granite'@'localhost' WITH GRANT OPTION; ``` 4. Export `.env` with `$ source ./export.sh` or `$ source .env`. 5. `$ crystal spec` ================================================ FILE: docker/docker-compose.mysql.yml ================================================ version: '2' services: spec: extends: file: ../docker-compose.yml service: spec environment: CURRENT_ADAPTER: mysql depends_on: - mysql mysql: image: mysql:${MYSQL_VERSION} environment: MYSQL_ROOT_PASSWORD: pass MYSQL_DATABASE: test MYSQL_USER: user MYSQL_PASSWORD: pass ================================================ FILE: docker/docker-compose.pg.yml ================================================ version: '2' services: spec: extends: file: ../docker-compose.yml service: spec environment: CURRENT_ADAPTER: pg depends_on: - pg pg: image: postgres:${PG_VERSION} environment: POSTGRES_PASSWORD: pass ================================================ FILE: docker/docker-compose.sqlite.yml ================================================ version: '2' services: spec: extends: file: ../docker-compose.yml service: spec build: context: ../ environment: CURRENT_ADAPTER: sqlite ================================================ FILE: docker-compose.yml ================================================ version: '2' services: spec: build: . command: 'bash -c "cd /app/user && bin/ameba && crystal tool format --check && crystal spec --warnings all"' working_dir: /app/user environment: PG_DATABASE_URL: 'postgres://postgres:pass@pg:5432/postgres' MYSQL_DATABASE_URL: 'mysql://user:pass@mysql:3306/test' SQLITE_DATABASE_URL: 'sqlite3:./test.db' CURRENT_ADAPTER: sqlite ================================================ FILE: docs/callbacks.md ================================================ # Callbacks Call a specified method on a specific life cycle event. Here is an example: ```crystal require "granite/adapter/pg" class Post < Granite::Base connection pg before_save :upcase_title column id : Int64, primary: true column title : String column content : String timestamps def upcase_title if title = @title @title = title.upcase end end end ``` You can register callbacks for the following events: ## Create - before_save - before_create - **save** - after_create - after_save ## Update - before_save - before_update - **save** - after_update - after_save ## Destroy - before_destroy - **destroy** - after_destroy ================================================ FILE: docs/crud.md ================================================ # CRUD ## Create Combination of object creation and insertion into database. ```crystal Post.create(name: "Granite Rocks!", body: "Check this out.") # Set attributes and call save Post.create!(name: "Granite Rocks!", body: "Check this out.") # Set attributes and call save!. Will throw an exception when the save failed ``` To create a record without setting the `created_at` & `updated_at` fields, you can pass in `skip_timestamps`. ```crystal Post.create({name: "Granite Rocks!", body: "Check this out."}, skip_timestamps: true) ``` ## Insert Inserts an already created object into the database. ```crystal post = Post.new post.name = "Granite Rocks!" post.body = "Check this out." post.save post = Post.new post.name = "Granite Rocks!" post.body = "Check this out." post.save! # raises when save failed ``` To skip the validation callbacks, pass in `validate: false`: ```crystal post.save(validate: false) post.save!(validate: false) ``` You can also pass in `skip_timestamps` to save without changing the `updated_at` field on update: ```crystal post.save(skip_timestamps: true) post.save!(skip_timestamps: true) ``` ## Read ### find Finds the record with the given primary key. ```crystal post = Post.find 1 if post puts post.name end post = Post.find! 1 # raises when no records found ``` ### find_by Finds the record(s) that match the given criteria ```crystal post = Post.find_by(slug: "example_slug") if post puts post.name end post = Post.find_by!(slug: "foo") # raises when no records found. other_post = Post.find_by(slug: "foo", type: "bar") # Also works for multiple arguments. ``` ### first Returns the first record. ```crystal post = Post.first if post puts post.name end post = Post.first! # raises when no records exist ``` ### reload Returns the record with the attributes reloaded from the database. **Note:** this method is only defined when the `Spec` module is present. ``` post = Post.create(name: "Granite Rocks!", body: "Check this out.") # record gets updated by another process post.reload # performs another find to fetch the record again ``` ### where, order, limit, offset, group_by See [querying](./querying.md) for more details of using the QueryBuilder. ### all Returns all records of a model. ```crystal posts = Post.all if posts posts.each do |post| puts post.name end end ``` See [querying](./querying.md#all) for more details on using `all` ## Update Updates a given record already saved in the database. ```crystal post = Post.find 1 post.name = "Granite Really Rocks!" post.save post = Post.find 1 post.update(name: "Granite Really Rocks!") # Assigns attributes and calls save post = Post.find 1 post.update!(name: "Granite Really Rocks!") # Assigns attributes and calls save!. Will throw an exception when the save failed ``` To update a record without changing the `updated_at` field, you can pass in `skip_timestamps`: ```crystal post = Post.find 1 post.update({name: "Granite Really Rocks!"}, skip_timestamps: true) post.update!({name: "Granite Really Rocks!"}, skip_timestamps: true) ``` ## Delete Delete a specific record. ```crystal post = Post.find 1 post.destroy if post puts "deleted" if post.destroyed? post = Post.find 1 post.destroy! # raises when delete failed ``` Clear all records of a model ```crystal Post.clear #truncate the table ``` ================================================ FILE: docs/imports.md ================================================ # Bulk Insertions ## Import > **Note:** Imports do not trigger callbacks automatically. See [Running Callbacks](#running-callbacks). Each model has an `.import` method that will save an array of models in one bulk insert statement. ```Crystal models = [ Model.new(id: 1, name: "Fred", age: 14), Model.new(id: 2, name: "Joe", age: 25), Model.new(id: 3, name: "John", age: 30), ] Model.import(models) ``` ## update_on_duplicate The `import` method has an optional `update_on_duplicate` + `columns` params that allows you to specify the columns (as an array of strings) that should be updated if primary constraint is violated. ```Crystal models = [ Model.new(id: 1, name: "Fred", age: 14), Model.new(id: 2, name: "Joe", age: 25), Model.new(id: 3, name: "John", age: 30), ] Model.import(models) Model.find!(1).name # => Fred models = [ Model.new(id: 1, name: "George", age: 14), ] Model.import(models, update_on_duplicate: true, columns: %w(name)) Model.find!(1).name # => George ``` **NOTE: If using PostgreSQL you must have version 9.5+ to have the on_duplicate_key_update feature.** ## ignore_on_duplicate The `import` method has an optional `ignore_on_duplicate` param, that takes a boolean, which will skip records if the primary constraint is violated. ```Crystal models = [ Model.new(id: 1, name: "Fred", age: 14), Model.new(id: 2, name: "Joe", age: 25), Model.new(id: 3, name: "John", age: 30), ] Model.import(models) Model.find!(1).name # => Fred models = [ Model.new(id: 1, name: "George", age: 14), ] Model.import(models, ignore_on_duplicate: true) Model.find!(1).name # => Fred ``` ## batch_size The `import` method has an optional `batch_size` param, that takes an integer. The batch_size determines the number of models to import in each INSERT statement. This defaults to the size of the models array, i.e. only 1 INSERT statement. ```Crystal models = [ Model.new(id: 1, name: "Fred", age: 14), Model.new(id: 2, name: "Joe", age: 25), Model.new(id: 3, name: "John", age: 30), Model.new(id: 3, name: "Bill", age: 66), ] Model.import(models, batch_size: 2) # => First SQL INSERT statement imports Fred and Joe # => Second SQL INSERT statement imports John and Bill ``` ## Running Callbacks Since the `import` method runs on the class level, callbacks are not triggered automatically, they have to be triggered manually. For example, using the Item class with a UUID primary key: ```Crystal require "uuid" class Item < Granite::Base connection mysql table items column item_id : String, primary: true, auto: false column item_name : String before_create :generate_uuid def generate_uuid @item_id = UUID.random.to_s end end ``` ```Crystal items = [ Item.new(item_name: "item1"), Item.new(item_name: "item2"), Item.new(item_name: "item3"), Item.new(item_name: "item4"), ] # If we did `Item.import(items)` now, it would fail since the item_id wouldn't get set before saving the record, violating the primary key constraint. # Manually run the callback on each model to generate the item_id. items.each(&.before_create) # Each model in the array now has a item_id set, so can be imported. Item.import(items) # This can also be used for a single record. item = Item.new(item_name: "item5") item.before_create item.save ``` > **Note:** Manually running your callbacks is mainly aimed at bulk imports. Running them before a normal `.save`, for example, would run your callbacks twice. ================================================ FILE: docs/migrations.md ================================================ # Migrations ## Database Migrations with micrate If you're using Granite to query your data, you likely want to manage your database schema as well. Migrations are a great way to do that, so let's take a look at [micrate](https://github.com/juanedi/micrate), a project to manage migrations. We'll use it as a dependency instead of a pre-build binary. ### Install Add micrate your shards.yml ```yaml dependencies: micrate: github: juanedi/micrate ``` Update shards ```sh $ shards update ``` Create an executable to run the `Micrate::Cli`. For this example, we'll create `bin/micrate` in the root of our project where we're using Granite ORM. This assumes you're exporting the `DATABASE_URL` for your project and an environment variable instead of using a `database.yml`. ```crystal #! /usr/bin/env crystal # # To build a standalone command line client, require the # driver you wish to use and use `Micrate::Cli`. # require "micrate" require "pg" Micrate::DB.connection_url = ENV["DATABASE_URL"] Micrate::Cli.run ``` Make it executable: ```sh $ chmod +x bin/micrate ``` We should now be able to run micrate commands. `$ bin/micrate help` => should output help commands. ### Creating a migration Let's create a `posts` table in our database. ```sh $ bin/micrate scaffold create_posts ``` This will create a file under `db/migrations`. Let's open it and define our posts schema. ```sql -- +micrate Up -- SQL in section 'Up' is executed when this migration is applied CREATE TABLE posts( id BIGSERIAL PRIMARY KEY, title VARCHAR NOT NULL, body TEXT NOT NULL, created_at TIMESTAMP, updated_at TIMESTAMP ); -- +micrate Down -- SQL section 'Down' is executed when this migration is rolled back DROP TABLE posts; ``` And now let's run the migration ```sh $ bin/micrate up ``` You should now have a `posts` table in your database ready to query. ================================================ FILE: docs/models.md ================================================ # Model Usage ## Multiple Connections It is possible to register multiple connections, for example: ```crystal Granite::Connections << Granite::Adapter::Mysql.new(name: "legacy_db", url: "LEGACY_DB_URL") Granite::Connections << Granite::Adapter::Pg.new(name: "new_db", url: "NEW_DB_URL") class Foo < Granite::Base connection legacy_db # model fields end class Bar < Granite::Base connection new_db # model fields end ``` In this example, we defined two connections. One to a MySQL database named "legacy_db", and another to a PG database named "new_db". The connection name given in the model maps to the name of a registered connection. > **NOTE:** How you store/supply each connection's URL is up to you; Granite only cares that it gets registered via `Granite::Connections << adapter_object`. ## timestamps The `timestamps` macro defines `created_at` and `updated_at` field for you. ```crystal class Bar < Granite::Base connection mysql # Other fields timestamps end ``` Would be equivalent to: ```crystal class Bar < Granite::Base connection mysql column created_at : Time? column updated_at : Time? end ``` ## Primary Keys Each model is required to have a primary key defined. Use the `column` macro with the `primary: true` option to denote the primary key. > **NOTE:** Composite primary keys are not yet supported. ```crystal class Site < Granite::Base connection mysql column id : Int64, primary: true column name : String end ``` `belongs_to` associations can also be used as a primary key in much the same way. ```crystal class ChatSettings < Granite::Base connection mysql # chat_id would be the primary key belongs_to chat : Chat, primary: true end ``` ### Custom The name and type of the primary key can also be changed from the recommended `id : Int64`. ```crystal class Site < Granite::Base connection mysql column custom_id : Int32, primary: true column name : String end ``` ### Natural Keys Primary keys are defined as auto incrementing by default. For natural keys, you can set `auto: false` option. ```crystal class Site < Granite::Base connection mysql column custom_id : Int32, primary: true, auto: false column name : String end ``` ### UUIDs For databases that utilize UUIDs as the primary key, the type of the primary key can be set to `UUID`. This will generate a secure UUID when the model is saved. ```crystal class Book < Granite::Base connection mysql column isbn : UUID, primary: true column name : String end book = Book.new book.name = "Moby Dick" book.isbn # => nil book.save book.isbn # => RFC4122 V4 UUID string ``` ## Default values A default value can be defined that will be used if another value is not specified/supplied. ```crystal class Book < Granite::Base connection mysql column id : Int64, primary: true column name : String = "DefaultBook" end book = Book.new book.name # => "DefaultBook" ``` ## Generating Documentation By default, running `crystal docs` will **not** include Granite methods, constants, and properties. To include these, use the `granite_docs` flag when generating the documentation. E.x. `crystal docs -D granite_docs`. Doc block comments can be applied above the `column` macro. ```crystal # If the item is public. column published : Bool ``` ## Annotations Annotations can be a powerful method of adding property specific features with minimal amounts of code. Since Granite utilizes the `property` keyword for its columns, annotations are able to be applied easily. These can either be `JSON::Field`, `YAML::Field`, or third party annotations. ```crystal class Foo < Granite::Base connection mysql table foos column id : Int64, primary: true @[JSON::Field(ignore: true)] @[Bar::Settings(other_option: 7)] column password : String column name : String column age : Int32 end ``` ## Converters Granite supports custom/special types via converters. Converters will convert the type into something the database can store when saving the model, and will convert the returned database value into that type on read. Each converter has a `T` generic argument that tells the converter what type should be read from the `DB::ResultSet`. For example, if you wanted to use the `JSON` converter and your underlying database column is `BLOB`, you would use `Bytes`, if it was `TEXT`, you would use `String`. Currently Granite supports various converters, each with their own supported database column types: - `Enum(E, T)` - Converts an Enum of type `E` to/from a database column of type `T`. Supported types for `T` are: `Number`, `String`, and `Bytes`. - `Json(M, T)` - Converters an `Object` of type `M` to/from a database column of type `T.` Supported types for `T` are: `String`, `JSON::Any`, and `Bytes`. - **NOTE:** `M` must implement `#to_json` and `.from_json` methods. - `PgNumeric` - Converts a `PG::Numeric` value to a `Float64` on read. The converter is defined on a per field basis. This example has an `OrderStatus` enum typed field. When saved, the enum value would be converted to a string to be stored in the DB. Then, when read, the string would be used to parse a new instance of `OrderStatus`. ```crystal enum OrderStatus Active Expired Completed end class Order < Granite::Base connection mysql table foos # Other fields column status : OrderStatus, converter: Granite::Converters::Enum(OrderStatus, String) end ``` ## Serialization Granite implements [JSON::Serializable](https://crystal-lang.org/api/JSON/Serializable.html) and [YAML::Serializable](https://crystal-lang.org/api/YAML/Serializable.html) by default. As such, models can be serialized to/from JSON/YAML via the `#to_json`/`#to_yaml` and `.from_json`/`.from_yaml` methods. ================================================ FILE: docs/multiple_connections.md ================================================ # Read replica support In Granite, you can create a connection that has a write/read node. If this is done. Granite will perform write operations on the primary node and read operations on the secondary node. Here is an example: ```crystal Granite::Connections << {name: "psql", writer: "...", reader: "...", adapter_type: Granite::Adapter::Pg} ``` The first parameter `name` is the name of the connection. When you create a model in Granite, you can specify a connection via the `connection` macro. If I wanted to use the above connection in a model. I would write ```crystal class User < Granite::Base connection "psql" end ``` where the value provided to the `connection` macro is the name of the granite connection you want to use. The `writer` is a connection string to the database node that has read/write access. The `reader` is a connection string to the database node that can only be read from. The final argument is a subclass of `Granite::Adapter::Base`. You're basically telling granite what kind of database adapter to use for this connection. Granite comes with adapters for Postgres, MySQL, and SQLite. ## configuring the connection switch wait period By default, when you perform a write operation on a Granite model, all read requests switch to using the primary database node. This is to allow the changes done to propogate to the read replicas before using them again. The default value is `2000` milliseconds. You can change this value like this ```crystal Granite::Conections.connection_switch_wait_period = 2000 #=> time in milliseconds ``` ================================================ FILE: docs/querying.md ================================================ # Querying The query macro and where clause combine to give you full control over your query. ## Where Where is using a QueryBuilder that allows you to chain where clauses together to build up a complete query. ```crystal posts = Post.where(published: true, author_id: User.first!.id) ``` It supports different operators: ```crystal Post.where(:created_at, :gt, Time.local - 7.days) ``` Supported operators are :eq, :gteq, :lteq, :neq, :gt, :lt, :nlt, :ngt, :ltgt, :in, :nin, :like, :nlike Alternatively, `#where`, `#and`, and `#or` accept a raw SQL clause, with an optional placeholder (`?` for MySQL/SQLite, `$` for Postgres) to avoid SQL Injection. ```crystal # Example using Postgres adapter Post.where(:created_at, :gt, Time.local - 7.days) .where("LOWER(author_name) = $", name) .where("tags @> '{"Journal", "Book"}') # PG's array contains operator ``` This is useful for building more sophisticated queries, including queries dependent on database specific features not supported by the operators above. However, **clauses built with this method are not validated.** ## Order Order is using the QueryBuilder and supports providing an ORDER BY clause: ```crystal Post.order(:created_at) ``` Direction ```crystal Post.order(updated_at: :desc) ``` Multiple fields ```crystal Post.order([:created_at, :title]) ``` With direction ```crystal Post.order(created_at: :desc, title: :asc) ``` ## Group By Group is using the QueryBuilder and supports providing an GROUP BY clause: ```crystal posts = Post.group_by(:published) ``` Multiple fields ```crystal Post.group_by([:published, :author_id]) ``` ## Limit Limit is using the QueryBuilder and provides the ability to limit the number of tuples returned: ```crystal Post.limit(50) ``` ## Offset Offset is using the QueryBuilder and provides the ability to offset the results. This is used for pagination: ```crystal Post.offset(100).limit(50) ``` ## All All is not using the QueryBuilder. It allows you to directly query the database using SQL. When using the `all` method, the selected fields will match the fields specified in the model unless the `select` macro was used to customize the SELECT. Always pass in parameters to avoid SQL Injection. Use a `?` in your query as placeholder. Checkout the [Crystal DB Driver](https://github.com/crystal-lang/crystal-db) for documentation of the drivers. Here are some examples: ```crystal posts = Post.all("WHERE name LIKE ?", ["Joe%"]) if posts posts.each do |post| puts post.name end end # ORDER BY Example posts = Post.all("ORDER BY created_at DESC") # JOIN Example posts = Post.all("JOIN comments c ON c.post_id = post.id WHERE c.name = ? ORDER BY post.created_at DESC", ["Joe"]) ``` ## Customizing SELECT The `select_statement` macro allows you to customize the entire query, including the SELECT portion. This shouldn't be necessary in most cases, but allows you to craft more complex (i.e. cross-table) queries if needed: ```crystal class CustomView < Granite::Base connection pg column id : Int64, primary: true column articlebody : String column commentbody : String select_statement <<-SQL SELECT articles.articlebody, comments.commentbody FROM articles JOIN comments ON comments.articleid = articles.id SQL end ``` You can combine this with an argument to `all` or `first` for maximum flexibility: ```crystal results = CustomView.all("WHERE articles.author = ?", ["Noah"]) ``` Note - the column order does matter, and you should match your SELECT query to have the columns in the same order they are in the database. ## Exists? The `exists?` class method returns `true` if a record exists in the table that matches the provided _id_ or _criteria_, otherwise `false`. If passed a `Number` or `String`, it will attempt to find a record with that primary key. If passed a `Hash` or `NamedTuple`, it will find the record that matches that criteria, similar to `find_by`. ```crystal # Assume a model named Post with a title field post = Post.new(title: "My Post") post.save post.id # => 1 Post.exists? 1 # => true Post.exists? {"id" => 1, :title => "My Post"} # => true Post.exists? {id: 1, title: "Some Post"} # => false ``` The `exists?` method can also be used with the query builder. ```crystal Post.where(published: true, author_id: User.first!.id).exists? Post.where(:created_at, :gt, Time.local - 7.days).exists? ``` ================================================ FILE: docs/readme.md ================================================ # Documentation ## Getting Started ### Installation Add this library to your projects dependencies along with the driver in your `shard.yml`. This can be used with any framework but was originally designed to work with the amber framework in mind. This library will work with Kemal or any other framework as well. ```yaml dependencies: granite: github: amberframework/granite # Pick your database mysql: github: crystal-lang/crystal-mysql sqlite3: github: crystal-lang/crystal-sqlite3 pg: github: will/crystal-pg ``` ### Register a Connection Next you will need to register a connection. This should be one of the first things in your main Crystal file, before Granite is required. ```crystal Granite::Connections << Granite::Adapter::Mysql.new(name: "mysql", url: "YOUR_DATABASE_URL") # Rest of code... ``` Supported adapters include: `Mysql, Pg, and Sqlite`. ### Example Model Here is an example Granite model using the connection registered above. ```crystal require "granite/adapter/mysql" class Post < Granite::Base connection mysql table posts # Name of the table to use for the model, defaults to class name snake cased column id : Int64, primary: true # Primary key, defaults to AUTO INCREMENT column name : String? # Nilable field column body : String # Not nil field end ``` ## Additional Documentation [Models](./models.md) [CRUD](./crud.md) [Querying](./querying.md) [Relationships](./relationships.md) [Validation](./validations.md) [Callbacks](./callbacks.md) [Migrations](./migrations.md) [Imports](./imports.md) ================================================ FILE: docs/relationships.md ================================================ # Relationships ## One to One For one-to-one relationships, You can use the `has_one` and `belongs_to` in your models. > **Note:** one-to-one relationship does not support through associations yet. ```crystal class Team < Granite::Base has_one :coach column id : Int64, primary: true column name : String end ``` This will add a `coach` and `coach=` instance methods to the team which returns associated coach. ```crystal class Coach < Granite::Base table coaches belongs_to :team column id : Int64, primary: true column name : String end ``` This will add a `team` and `team=` instance method to the coach. For example: ```crystal team = Team.find! 1 # has_one side.. puts team.coach coach = Coach.find! 1 # belongs_to side... puts coach.team coach.team = team coach.save # or in one-to-one you can also do team.coach = coach # coach is the child entity and contians the foreign_key # so save should called on coach instance coach.save ``` In this example, you will need to add a `team_id` and index to your coaches table: ```sql CREATE TABLE coaches ( id BIGSERIAL PRIMARY KEY, team_id BIGINT, name VARCHAR, created_at TIMESTAMP, updated_at TIMESTAMP ); CREATE INDEX team_id_idx ON coaches (team_id); ``` Foreign key is inferred from the class name of the Model which uses `has_one`. In above case `team_id` is assumed to be present in `coaches` table. In case its different you can specify one like this: ```crystal class Team < Granite::Base has_one :coach, foreign_key: :custom_id column id : Int64, primary: true column name : String end class Coach < Granite::Base belongs_to :team column id : Int64, primary: true end ``` The class name inferred from the name but you can specify the class name: ```crystal class Team < Granite::Base has_one coach : Coach, foreign_key: :custom_id # or you can provide the class name as a parameter has_one :coach, class_name: Coach, foreign_key: :custom_id column id : Int64, primary: true column name : String end class Coach < Granite::Base belongs_to team : Team # provide a custom foreign key belongs_to team : Team, foreign_key: team_uuid : String column id : Int64, primary: true end ``` ## One to Many `belongs_to` and `has_many` macros provide a rails like mapping between Objects. ```crystal class User < Granite::Base connection mysql has_many :post # pluralization requires providing the class name has_many posts : Post # or you can provide class name as a parameter has_many :posts, class_name: Post # you can provide a custom foreign key has_many :posts, class_name: Post, foreign_key: :custom_id column id : Int64, primary: true column name : String column email : String timestamps end ``` This will add a `posts` instance method to the user which returns an array of posts. ```crystal class Post < Granite::Base connection mysql table posts belongs_to :user # or custom name belongs_to my_user : User # or custom foreign key belongs_to user : User, foreign_key: uuid : String column id : Int64, primary: true column title : String timestamps end ``` This will add a `user` and `user=` instance method to the post. For example: ```crystal user = User.find! 1 user.posts.each do |post| puts post.title end post = Post.find! 1 puts post.user post.user = user post.save ``` In this example, you will need to add a `user_id` and index to your posts table: ```sql CREATE TABLE posts ( id BIGSERIAL PRIMARY KEY, user_id BIGINT, title VARCHAR, created_at TIMESTAMP, updated_at TIMESTAMP ); CREATE INDEX user_id_idx ON posts (user_id); ``` ## Many to Many Instead of using a hidden many-to-many table, Granite recommends always creating a model for your join tables. For example, let's say you have many `users` that belong to many `rooms`. We recommend adding a new model called `participants` to represent the many-to-many relationship. Then you can use the `belongs_to` and `has_many` relationships going both ways. ```crystal class User < Granite::Base has_many :participants, class_name: Participant column id : Int64, primary: true column name : String end class Participant < Granite::Base table participants belongs_to :user belongs_to :room column id : Int64, primary: true end class Room < Granite::Base table rooms has_many :participants, class_name: Participant column id : Int64, primary: true column name : String end ``` The Participant class represents the many-to-many relationship between the Users and Rooms. Here is what the database table would look like: ```sql CREATE TABLE participants ( id BIGSERIAL PRIMARY KEY, user_id BIGINT, room_id BIGINT, created_at TIMESTAMP, updated_at TIMESTAMP ); CREATE INDEX user_id_idx ON TABLE participants (user_id); CREATE INDEX room_id_idx ON TABLE participants (room_id); ``` ## has_many through: As a convenience, we provide a `through:` clause to simplify accessing the many-to-many relationship: ```crystal class User < Granite::Base has_many :participants, class_name: Participant has_many :rooms, class_name: Room, through: :participants column id : Int64, primary: true column name : String end class Participant < Granite::Base belongs_to :user belongs_to :room column id : Int64, primary: true end class Room < Granite::Base has_many :participants, class_name: Participant has_many :users, class_name: User, through: :participants column id : Int64, primary: true column name : String end ``` This will allow you to find all the rooms that a user is in: ```crystal user = User.create(name: "Bob") room = Room.create(name: "#crystal-lang") room2 = Room.create(name: "#amber") Participant.create(user_id: user.id, room_id: room.id) Participant.create(user_id: user.id, room_id: room2.id) user.rooms.each do |room| puts room.name end ``` And the reverse, all the users in a room: ```crystal room.users.each do |user| puts user.name end ``` ================================================ FILE: docs/validations.md ================================================ # Errors All database errors are added to the `errors` array used by `Granite::Validators` with the symbol `:base` ```crystal post = Post.new post.save post.errors[0].to_s.should eq "ERROR: name cannot be null" ``` ## Validations Validations can be made on models to ensure that given criteria are met. Models that do not pass the validations will not be saved, and will have the errors added to the model's `errors` array. For example, asserting that the title on a post is not blank: ```Crystal class Post < Granite::Base connection mysql column id : Int64, primary: true column title : String validate :title, "can't be blank" do |post| !post.title.to_s.blank? end end ` ``` ## Validation Helpers A set of common validation macros exist to make validations easier to manage/create. ### Common - `validate_not_nil :field` - Validates that field should not be nil. - `validate_is_nil :field` - Validates that field should be nil. - `validate_is_valid_choice :type, ["allowedType1", "allowedType2"]` - Validates that type should be one of a preset option. - `validate_exclusion :type, ["notAllowedType1", "notAllowedType2"]` - Validates that type should not be one of a preset option. - `validate_uniqueness :field` - Validates that the field is unique ### String - `validate_not_blank :field` - Validates that field should not be blank. - `validate_is_blank :field` - Validates that field should be blank. - `validate_min_length :field, 5` - Validates that field should be at least 5 long - `validate_max_length :field, 20` - Validates that field should be at most 20 long ### String - `validate_greater_than :field, 0` - Validates that field should be greater than 0. - `validate_greater_than :field, 0, true` - Validates that field should be greater than or equal to 0. - `validate_less_than :field, 100` - Validates that field should be less than 100. - `validate_less_than :field, 100, true` - Validates that field should be less than or equal to 100. Using the helpers, the previous example could have been written like: ```Crystal class Post < Granite::Base connection mysql column id : Int64, primary: true column title : String validate_not_blank :title end ``` ================================================ FILE: export.sh ================================================ #!/bin/bash if [ -f .env ]; then while IFS= read -r line; do export "$line" done < .env echo "Environment variables from .env file have been exported." else echo "Error: The .env file does not exist." fi ================================================ FILE: shard.yml ================================================ name: granite version: 0.23.4 crystal: ">= 1.6.0, < 2.0.0" authors: - drujensen - elorest license: MIT dependencies: db: github: crystal-lang/crystal-db version: ~> 0.13.1 development_dependencies: mysql: github: crystal-lang/crystal-mysql version: ~> 0.16.0 sqlite3: github: crystal-lang/crystal-sqlite3 version: ~> 0.21.0 pg: github: will/crystal-pg version: ~> 0.29.0 ameba: github: crystal-ameba/ameba version: ~> 1.5.0 ================================================ FILE: spec/adapter/adapters_spec.cr ================================================ require "../spec_helper" class Foo < Granite::Base connection {{env("CURRENT_ADAPTER").id}} column id : Int64, primary: true end class Bar < Granite::Base column id : Int64, primary: true end describe Granite::Connections do describe "registration" do it "should allow connections to be be saved and looked up" do Granite::Connections.registered_connections.size.should eq 2 if connection = Granite::Connections[CURRENT_ADAPTER] connection[:writer].url.should eq ADAPTER_URL else connection.should_not be_falsey end case ENV["CURRENT_ADAPTER"]? when "sqlite" if connection = Granite::Connections["sqlite_with_replica"] connection[:writer].url.should eq ENV["SQLITE_DATABASE_URL"]? connection[:reader].url.should eq ADAPTER_REPLICA_URL else connection.should_not be_falsey end end end it "should disallow multiple connections with the same name" do Granite::Connections << Granite::Adapter::Pg.new(name: "mysql2", url: "mysql://localhost:3306/test") expect_raises(Exception, "Adapter with name 'mysql2' has already been registered.") do Granite::Connections << Granite::Adapter::Pg.new(name: "mysql2", url: "mysql://localhost:3306/test") end end it "should assign the correct connections to a model" do adapter = Foo.adapter adapter.name.should eq CURRENT_ADAPTER adapter.url.should eq ADAPTER_URL end it "should use the first registered connection if none are specified" do adapter = Bar.adapter adapter.name.should eq CURRENT_ADAPTER adapter.url.should eq ADAPTER_URL end end end ================================================ FILE: spec/adapter/mysql_spec.cr ================================================ require "../spec_helper" ================================================ FILE: spec/adapter/pg_spec.cr ================================================ require "../spec_helper" ================================================ FILE: spec/adapter/sqlite_spec.cr ================================================ require "../spec_helper" ================================================ FILE: spec/granite/associations/belongs_to_spec.cr ================================================ require "../../spec_helper" describe "belongs_to" do it "provides a getter for the foreign entity" do teacher = Teacher.new teacher.name = "Test teacher" teacher.save klass = Klass.new klass.name = "Test klass" klass.teacher_id = teacher.id klass.save klass.teacher.id.should eq teacher.id end it "provides a setter for the foreign entity" do teacher = Teacher.new teacher.name = "Test teacher" teacher.save klass = Klass.new klass.name = "Test klass" klass.teacher = teacher klass.save klass.teacher_id.should eq teacher.id end it "supports custom types for the join" do book = Book.new book.name = "Screw driver" book.save review = BookReview.new review.book = book review.body = "Best book ever!" review.save review.book.name.should eq "Screw driver" end it "supports custom method name" do author = Person.new author.name = "John Titor" author.save book = Book.new book.name = "How to Time Traveling" book.author = author book.save book.author.name.should eq "John Titor" end it "supports both custom method name and custom types for the join" do publisher = Company.new publisher.name = "Amber Framework" publisher.save book = Book.new book.name = "Introduction to Granite" book.publisher = publisher book.save book.publisher.name.should eq "Amber Framework" end it "supports json_options" do publisher = Company.new publisher.name = "Amber Framework" publisher.save book = Book.new book.name = "Introduction to Granite" book.publisher = publisher book.save book.to_json.should eq %({"id":#{book.id},"name":"Introduction to Granite"}) end it "supports yaml_options" do publisher = Company.new publisher.name = "Amber Framework" publisher.save book = Book.new book.name = "Introduction to Granite" book.publisher = publisher book.save book.to_yaml.should eq %(---\nid: #{book.id}\nname: Introduction to Granite\n) end it "provides a method to retrieve parent object that will raise if record is not found" do book = Book.new book.name = "Introduction to Granite" expect_raises Granite::Querying::NotFound, "No Company found where id is NULL" { book.publisher! } end it "provides the ability to use a custom primary key" do courier = Courier.new courier.courier_id = 139_132_751 courier.issuer_id = 999 service = CourierService.new service.owner_id = 123_321 service.name = "My Service" service.save courier.service = service courier.save courier.service!.owner_id.should eq 123_321 end it "allows a belongs_to association to be a primary key" do chat = Chat.new chat.name = "My Awesome Chat" chat.save settings = ChatSettings.new settings.chat = chat settings.save settings.chat_id!.should eq chat.id end it "provides the ability to define a converter for the foreign key" do uuid_model = UUIDModel.new uuid_model.save uuid_relation = UUIDRelation.new uuid_relation.uuid_model = uuid_model uuid_relation.save uuid_relation.uuid_model_id.should eq uuid_model.uuid end end ================================================ FILE: spec/granite/associations/has_many_spec.cr ================================================ require "../../spec_helper" describe "has_many" do it "provides a method to retrieve associated objects" do teacher = Teacher.new teacher.name = "test teacher" teacher.save class1 = Klass.new class1.name = "Test class 1" class1.teacher = teacher class1.save class2 = Klass.new class2.name = "Test class 2" class2.teacher = teacher class2.save class3 = Klass.new class3.name = "Test class 3" class3.save teacher.klasses.size.should eq 2 end context "querying association" do it "#all" do teacher = Teacher.new teacher.name = "test teacher" teacher.save klass1 = Klass.new klass1.name = "Test class X" klass1.teacher = teacher klass1.save klass2 = Klass.new klass2.name = "Test class X" klass2.teacher = teacher klass2.save klass3 = Klass.new klass3.name = "Test class with different name" klass3.teacher = teacher klass3.save klasses = teacher.klasses.all("AND klasses.name = ? ORDER BY klasses.id DESC", ["Test class X"]) klasses.map(&.id).should eq [klass2.id, klass1.id] end it "#find_by" do teacher = Teacher.new teacher.name = "test teacher" teacher.save klass1 = Klass.new klass1.name = "Test class X" klass1.teacher = teacher klass1.save klass2 = Klass.new klass2.name = "Test class X" klass2.teacher = teacher klass2.save klass3 = Klass.new klass3.name = "Test class with different name" klass3.teacher = teacher klass3.save klass = teacher.klasses.find_by(name: "Test class with different name") if klass klass.id.should eq klass3.id klass.name.should eq "Test class with different name" else klass.should_not be_nil end end it "#find_by!" do teacher = Teacher.new teacher.name = "test teacher" teacher.save klass1 = Klass.new klass1.name = "Test class X" klass1.teacher = teacher klass1.save klass2 = Klass.new klass2.name = "Test class X" klass2.teacher = teacher klass2.save klass3 = Klass.new klass3.name = "Test class with different name" klass3.teacher = teacher klass3.save klass = teacher.klasses.find_by!(name: "Test class with different name") klass.id.should eq klass3.id klass.name.should eq "Test class with different name" expect_raises( Granite::Querying::NotFound, "No #{Klass.name} found where name = not_found" ) do klass = teacher.klasses.find_by!(name: "not_found") end end it "#find" do teacher = Teacher.new teacher.name = "test teacher" teacher.save klass1 = Klass.new klass1.name = "Test class X" klass1.teacher = teacher klass1.save klass2 = Klass.new klass2.name = "Test class X" klass2.teacher = teacher klass2.save klass3 = Klass.new klass3.name = "Test class with different name" klass3.teacher = teacher klass3.save klass = teacher.klasses.find(klass1.id) if klass klass.id.should eq klass1.id klass.name.should eq "Test class X" else klass.should_not be_nil end end it "#find!" do teacher = Teacher.new teacher.name = "test teacher" teacher.save klass1 = Klass.new klass1.name = "Test class X" klass1.teacher = teacher klass1.save klass2 = Klass.new klass2.name = "Test class X" klass2.teacher = teacher klass2.save klass3 = Klass.new klass3.name = "Test class with different name" klass3.teacher = teacher klass3.save klass = teacher.klasses.find!(klass1.id) klass.id.should eq klass1.id klass.name.should eq "Test class X" id = klass3.id.as(Int64) + 42 expect_raises( Granite::Querying::NotFound, "No #{Klass.name} found where id = #{id}" ) do teacher.klasses.find!(id) end end it "should respect the current primary key" do courier1 = Courier.new courier1.courier_id = 1 courier1.issuer_id = 1 courier1.service_id = 1 courier1.save courier2 = Courier.new courier2.courier_id = 2 courier2.issuer_id = 2 courier2.service_id = 1 courier2.save courier3 = Courier.new courier3.courier_id = 3 courier3.issuer_id = 3 courier3.service_id = 1 courier3.save service = CourierService.new service.name = "My service" service.owner_id = 1 couriers = service.couriers.to_a couriers.size.should eq 3 couriers[0].courier_id.should eq courier1.courier_id couriers[0].issuer_id.should eq courier1.issuer_id couriers[1].courier_id.should eq courier2.courier_id couriers[1].issuer_id.should eq courier2.issuer_id couriers[2].courier_id.should eq courier3.courier_id couriers[2].issuer_id.should eq courier3.issuer_id end end end ================================================ FILE: spec/granite/associations/has_many_through_spec.cr ================================================ require "../../spec_helper" describe "has_many, through:" do it "provides a method to retrieve associated objects through another table" do student = Student.new student.name = "test student" student.save unrelated_student = Student.new unrelated_student.name = "other student" unrelated_student.save klass1 = Klass.new klass1.name = "Test class" klass1.save klass2 = Klass.new klass2.name = "Test class" klass2.save klass3 = Klass.new klass3.name = "Test class" klass3.save enrollment1 = Enrollment.new enrollment1.student = student enrollment1.klass = klass1 enrollment1.save enrollment2 = Enrollment.new enrollment2.student = student enrollment2.klass = klass2 enrollment2.save enrollment3 = Enrollment.new enrollment3.klass = klass2 enrollment3.student = unrelated_student enrollment3.save student.klasses.compact_map(&.id).sort!.should eq [klass1.id, klass2.id].compact.sort! klass2.students.compact_map(&.id).sort!.should eq [student.id, unrelated_student.id].compact.sort! end context "querying association" do it "#all" do student = Student.create(name: "test student") klass1 = Klass.create(name: "Test class X") klass2 = Klass.create(name: "Test class X") klass3 = Klass.create(name: "Test class with different name") enrollment1 = Enrollment.new enrollment1.student = student enrollment1.klass = klass1 enrollment1.save enrollment2 = Enrollment.new enrollment2.student = student enrollment2.klass = klass2 enrollment2.save enrollment3 = Enrollment.new enrollment3.klass = klass3 enrollment3.student = student enrollment3.save klasses = student.klasses.all("AND klasses.name = ? ORDER BY klasses.id DESC", ["Test class X"]) klasses.map(&.id).should eq [klass2.id, klass1.id] end it "#find_by" do student = Student.create(name: "test student") klass1 = Klass.create(name: "Test class X") klass2 = Klass.create(name: "Test class X") klass3 = Klass.create(name: "Test class with different name") enrollment1 = Enrollment.new enrollment1.student = student enrollment1.klass = klass1 enrollment1.save enrollment2 = Enrollment.new enrollment2.student = student enrollment2.klass = klass2 enrollment2.save enrollment3 = Enrollment.new enrollment3.klass = klass3 enrollment3.student = student enrollment3.save klass = student.klasses.find_by(name: "Test class with different name") if klass klass.id.should eq klass3.id klass.name.should eq "Test class with different name" else klass.should_not be_nil end end it "#find_by!" do student = Student.create(name: "test student") klass1 = Klass.create(name: "Test class X") klass2 = Klass.create(name: "Test class X") klass3 = Klass.create(name: "Test class with different name") enrollment1 = Enrollment.new enrollment1.student = student enrollment1.klass = klass1 enrollment1.save enrollment2 = Enrollment.new enrollment2.student = student enrollment2.klass = klass2 enrollment2.save enrollment3 = Enrollment.new enrollment3.klass = klass3 enrollment3.student = student enrollment3.save klass = student.klasses.find_by!(name: "Test class with different name") klass.id.should eq klass3.id klass.name.should eq "Test class with different name" expect_raises( Granite::Querying::NotFound, "No #{Klass.name} found where name = not_found" ) do klass = student.klasses.find_by!(name: "not_found") end end it "#find" do student = Student.create(name: "test student") klass1 = Klass.create(name: "Test class X") klass2 = Klass.create(name: "Test class X") klass3 = Klass.create(name: "Test class with different name") enrollment1 = Enrollment.new enrollment1.student = student enrollment1.klass = klass1 enrollment1.save enrollment2 = Enrollment.new enrollment2.student = student enrollment2.klass = klass2 enrollment2.save enrollment3 = Enrollment.new enrollment3.klass = klass3 enrollment3.student = student enrollment3.save klass = student.klasses.find(klass1.id) if klass klass.id.should eq klass1.id klass.name.should eq "Test class X" else klass.should_not be_nil end end it "#find!" do student = Student.create(name: "test student") klass1 = Klass.create(name: "Test class X") klass2 = Klass.create(name: "Test class X") klass3 = Klass.create(name: "Test class with different name") enrollment1 = Enrollment.new enrollment1.student = student enrollment1.klass = klass1 enrollment1.save enrollment2 = Enrollment.new enrollment2.student = student enrollment2.klass = klass2 enrollment2.save enrollment3 = Enrollment.new enrollment3.klass = klass3 enrollment3.student = student enrollment3.save klass = student.klasses.find!(klass1.id) klass.id.should eq klass1.id klass.name.should eq "Test class X" id = klass3.id.as(Int64) + 42 expect_raises( Granite::Querying::NotFound, "No #{Klass.name} found where id = #{id}" ) do student.klasses.find!(id) end end end end ================================================ FILE: spec/granite/associations/has_one_spec.cr ================================================ require "../../spec_helper" describe "has_one" do before_each do User.clear Profile.clear Courier.clear Character.clear end it "provides a setter to set childrens's foriegn_key from parent" do profile = Profile.new profile.name = "Test Profile" profile.save user = User.new user.email = "test@domain.com" user.save user.profile = profile profile.user_id.should eq profile.id end it "provides a method to retrieve associated objects" do profile = Profile.new profile.name = "Test Profile" profile.save user = User.new user.email = "test@domain.com" user.save # profile's foriegn_key is now set, so calling save again user.profile = profile profile.save retrieved_profile = user.profile! retrieved_profile.id.should eq profile.id end it "provides a method to retrieve associated object that will raise if record is not found" do user = User.new user.email = "test@domain.com" user.save! expect_raises Granite::Querying::NotFound, "No Profile found where user_id = #{user.id}" { user.profile! } end it "provides the ability to use a custom primary key" do courier = Courier.new courier.courier_id = 139_132_750 courier.issuer_id = 999 character = Character.new character.character_id = 999 character.name = "Mr Jones" character.save courier.issuer = character courier.save courier.issuer!.character_id.should eq 999 end end ================================================ FILE: spec/granite/callbacks/abort_spec.cr ================================================ require "../../spec_helper" describe "#abort!" do before_each do CallbackWithAbort.clear end context "when create" do it "doesn't run other callbacks if abort at before_save" do cwa = CallbackWithAbort.new(abort_at: "before_save", do_abort: true) cwa.save cwa.errors.map(&.to_s).should eq(["Aborted at before_save."]) cwa.history.to_s.strip.should eq("") CallbackWithAbort.find("before_save").should be_nil end it "only runs before_save if abort at before_create" do cwa = CallbackWithAbort.new(abort_at: "before_create", do_abort: true) cwa.save cwa.errors.map(&.to_s).should eq(["Aborted at before_create."]) cwa.history.to_s.strip.should eq <<-RUNS before_save RUNS CallbackWithAbort.find("before_create").should be_nil end it "runs before_save, before_create and save successfully if abort at after_create" do cwa = CallbackWithAbort.new(abort_at: "after_create", do_abort: true) cwa.save cwa.errors.map(&.to_s).should eq(["Aborted at after_create."]) cwa.history.to_s.strip.should eq <<-RUNS before_save before_create RUNS CallbackWithAbort.find("after_create").should be_a(CallbackWithAbort) end it "runs before_save, before_create, after_create and save successfully if abort at after_save" do cwa = CallbackWithAbort.new(abort_at: "after_save", do_abort: true) cwa.save cwa.errors.map(&.to_s).should eq(["Aborted at after_save."]) cwa.history.to_s.strip.should eq <<-RUNS before_save before_create after_create RUNS CallbackWithAbort.find("after_save").should be_a(CallbackWithAbort) end end context "when update" do it "doesn't run other callbacks if abort at before_save" do CallbackWithAbort.new(abort_at: "before_save", do_abort: false).save cwa = CallbackWithAbort.find!("before_save") cwa.do_abort = true cwa.save cwa.errors.map(&.to_s).should eq(["Aborted at before_save."]) cwa.history.to_s.strip.should eq("") CallbackWithAbort.find!("before_save").do_abort.should be_false end it "only runs before_save if abort at before_update" do CallbackWithAbort.new(abort_at: "before_update", do_abort: false).save cwa = CallbackWithAbort.find!("before_update") cwa.do_abort = true cwa.save cwa.errors.map(&.to_s).should eq(["Aborted at before_update."]) cwa.history.to_s.strip.should eq <<-RUNS before_save RUNS CallbackWithAbort.find!("before_update").do_abort.should be_false end it "runs before_save, before_update and save successfully if abort at after_update" do CallbackWithAbort.new(abort_at: "after_update", do_abort: false).save cwa = CallbackWithAbort.find!("after_update") cwa.do_abort = true cwa.save cwa.errors.map(&.to_s).should eq(["Aborted at after_update."]) cwa.history.to_s.strip.should eq <<-RUNS before_save before_update RUNS CallbackWithAbort.find!("after_update").do_abort.should be_true end it "runs before_save, before_update, after_update and save successfully if abort at after_save" do CallbackWithAbort.new(abort_at: "after_save", do_abort: false).save cwa = CallbackWithAbort.find!("after_save") cwa.do_abort = true cwa.save cwa.errors.map(&.to_s).should eq(["Aborted at after_save."]) cwa.history.to_s.strip.should eq <<-RUNS before_save before_update after_update RUNS CallbackWithAbort.find!("after_save").do_abort.should be_true end end context "when destroy" do it "doesn't run other callbacks if abort at before_destroy" do CallbackWithAbort.new(abort_at: "before_destroy", do_abort: true).save cwa = CallbackWithAbort.find!("before_destroy") cwa.destroy cwa.errors.map(&.to_s).should eq(["Aborted at before_destroy."]) cwa.history.to_s.strip.should eq("") CallbackWithAbort.find("before_destroy").should be_a(CallbackWithAbort) end it "runs before_destroy and destroy successfully if abort at after_destory" do CallbackWithAbort.new(abort_at: "after_destroy", do_abort: true).save cwa = CallbackWithAbort.find!("after_destroy") cwa.destroy cwa.errors.map(&.to_s).should eq(["Aborted at after_destroy."]) cwa.history.to_s.strip.should eq <<-RUNS before_destroy RUNS CallbackWithAbort.find("after_destroy").should be_nil end end end ================================================ FILE: spec/granite/callbacks/callbacks_spec.cr ================================================ require "../../spec_helper" describe "(callback feature)" do describe "#save (new record)" do it "runs before_save, before_create, after_create, after_save" do callback = Callback.new(name: "foo") callback.save callback.history.to_s.strip.should eq <<-EOF before_save before_create after_create after_save EOF end end describe "#save" do it "runs before_save, before_update, after_update, after_save" do Callback.new(name: "foo").save callback = Callback.first! callback.save callback.history.to_s.strip.should eq <<-EOF before_save before_update after_update after_save EOF end end describe "#destroy" do it "runs before_destroy, after_destroy" do Callback.new(name: "foo").save callback = Callback.first! callback.destroy callback.history.to_s.strip.should eq <<-EOF before_destroy after_destroy EOF end end describe "an exception thrown in a hook" do it "should not get swallowed" do callback = Callback.new(name: "foo") # close IO in order to raise IO::Error in callback blocks callback.history.close expect_raises(IO::Error, "Closed stream") do callback.save end end end describe "manually triggered" do context "on a single model" do it "should successfully trigger the callback" do item = Item.new(item_name: "item1") item.item_id.should be_nil item.before_create item.item_id.should be_a(String) end end context "on an array of models" do it "should successfully trigger the callback" do items = [] of Item items << Item.new(item_name: "item1") items << Item.new(item_name: "item2") items << Item.new(item_name: "item3") items << Item.new(item_name: "item4") items.all? { |item| item.item_id.nil? }.should be_true items.each(&.before_create) items.all? { |item| item.item_id.is_a?(String) }.should be_true end end end end ================================================ FILE: spec/granite/columns/primary_key_spec.cr ================================================ require "../../spec_helper" describe "#new" do it "works when the primary is defined as `auto: true`" do Parent.new end it "works when the primary is defined as `auto: false`" do Kvs.new end end describe "#new(primary_key: value)" do it "ignores the value in default" do Parent.new(id: 1_i64).id.should eq(nil) end it "sets the value when the primary is defined as `auto: false`" do Kvs.new(k: "foo").k.should eq("foo") Kvs.new(k: "foo", v: "v").k.should eq("foo") end end ================================================ FILE: spec/granite/columns/read_attribute_spec.cr ================================================ require "../../spec_helper" describe "read_attribute" do # Only PG supports array types {% if env("CURRENT_ADAPTER") == "pg" %} it "able to read arrays" do ArrayModel.new.read_attribute("i32_array").should be_nil end {% end %} end ================================================ FILE: spec/granite/columns/timestamps_spec.cr ================================================ require "../../spec_helper" # Can run this spec for sqlite after https://www.sqlite.org/draft/releaselog/3_24_0.html is released. {% if ["pg", "mysql"].includes? env("CURRENT_ADAPTER") %} describe "timestamps" do it "should uses UTC for created_at by default" do parent = Parent.new(name: "parent").tap(&.save) found_parent = Parent.find!(parent.id) original_timestamp = parent.created_at! read_timestamp = found_parent.created_at! original_timestamp.location.should eq Time::Location::UTC read_timestamp.location.should eq Time::Location::UTC end it "should uses UTC for updated_at by default" do parent = Parent.new(name: "parent").tap(&.save) found_parent = Parent.find!(parent.id) original_timestamp = parent.updated_at! read_timestamp = found_parent.updated_at! original_timestamp.location.should eq Time::Location::UTC read_timestamp.location.should eq Time::Location::UTC end it "should uses timezone for created_at" do Granite.settings.default_timezone = "Asia/Shanghai" parent = Parent.new(name: "parent").tap(&.save) found_parent = Parent.find!(parent.id) original_timestamp = parent.created_at! read_timestamp = found_parent.created_at! original_timestamp.location.should eq Time::Location.load("Asia/Shanghai") read_timestamp.location.should eq Time::Location.load("Asia/Shanghai") end it "should uses timezone for updated_at" do Granite.settings.default_timezone = "Asia/Shanghai" parent = Parent.new(name: "parent").tap(&.save) found_parent = Parent.find!(parent.id) original_timestamp = parent.updated_at! read_timestamp = found_parent.updated_at! original_timestamp.location.should eq Time::Location.load("Asia/Shanghai") read_timestamp.location.should eq Time::Location.load("Asia/Shanghai") end it "truncates the subsecond parts of created_at" do parent = Parent.new(name: "parent").tap(&.save) found_parent = Parent.find!(parent.id) original_timestamp = parent.created_at! read_timestamp = found_parent.created_at! original_timestamp.to_unix.should eq read_timestamp.to_unix end it "truncates the subsecond parts of updated_at" do parent = Parent.new(name: "parent").tap(&.save) found_parent = Parent.find!(parent.id) original_timestamp = parent.updated_at! read_timestamp = found_parent.updated_at! original_timestamp.to_unix.should eq read_timestamp.to_unix end context "bulk imports" do it "timestamps are returned correctly with bulk imports" do to_import = [ Parent.new(name: "ParentImport1"), Parent.new(name: "ParentImport2"), Parent.new(name: "ParentImport3"), ] grandma = Parent.new(name: "grandma").tap(&.save) found_grandma = Parent.find! grandma.id Parent.import(to_import) parents = Parent.all("WHERE name LIKE ?", ["ParentImport%"]) parents.size.should eq 3 parents.each do |parent| parent.updated_at.not_nil!.location.should eq Time::Location::UTC parent.created_at.not_nil!.location.should eq Time::Location::UTC found_grandma.updated_at.not_nil!.to_unix.should eq parent.updated_at.not_nil!.to_unix found_grandma.created_at.not_nil!.to_unix.should eq parent.created_at.not_nil!.to_unix end end it "created_at and updated_at are correctly handled" do to_import = [ Parent.new(name: "ParentOne"), ] Parent.import(to_import) import_time = Time.utc.at_beginning_of_second parent1 = Parent.find_by!(name: "ParentOne") parent1.name.should eq "ParentOne" parent1.created_at!.should eq import_time parent1.updated_at!.should eq import_time to_update = Parent.all("WHERE name = ?", ["ParentOne"]) to_update.each { |parent| parent.name = "ParentOneEdited" } sleep 1 Parent.import(to_update, update_on_duplicate: true, columns: ["name"]) update_time = Time.utc.at_beginning_of_second parent1_edited = Parent.find_by!(name: "ParentOneEdited") parent1_edited.name.should eq "ParentOneEdited" parent1_edited.created_at!.should be_close(import_time, 1.second) parent1_edited.updated_at!.should be_close(update_time, 1.second) end end end {% end %} ================================================ FILE: spec/granite/columns/uuid_spec.cr ================================================ require "../../spec_helper" describe "UUID creation" do it "correctly sets a RFC4122 V4 UUID on save" do item = UUIDModel.new item.uuid.should be_nil item.save item.uuid.should be_a(UUID) item.uuid!.version.v4?.should be_true item.uuid!.variant.rfc4122?.should be_true end end ================================================ FILE: spec/granite/connection_management_spec.cr ================================================ require "spec" describe "Granite::Base track time since last write" do it "should switch to reader db connection after connection_switch_wait_period after write operation" do ReplicatedChat.connection_switch_wait_period = 250 ReplicatedChat.new(content: "hello world!").save! sleep 500.milliseconds current_url = ReplicatedChat.adapter.url reader_connection = Granite::Connections["#{ENV["CURRENT_ADAPTER"]}_with_replica"] raise "Reader connection cannot be nil" if reader_connection.nil? reader_url = reader_connection[:reader].url current_url.should eq reader_url end end ================================================ FILE: spec/granite/converters/converters_spec.cr ================================================ require "../../spec_helper" describe Granite::Converters do {% if env("CURRENT_ADAPTER") == "pg" %} describe "#save" do it "should handle nil values" do model = ConverterModel.new model.save.should be_true model.id.should be_a Int64 # Enums model.smallint_enum.should be_nil model.bigint_enum.should be_nil model.string_enum.should be_nil model.enum_enum.should be_nil model.binary_enum.should be_nil # Numeric model.numeric.should be_nil # JSON model.string_json.should be_nil model.string_jsonb.should be_nil model.binary_json.should be_nil end it "should handle actual values" do obj = MyType.new model = ConverterModel.new numeric: Math::PI.round(20) model.binary_json = model.string_jsonb = model.string_json = obj model.smallint_enum = MyEnum::Zero model.bigint_enum = MyEnum::One model.string_enum = MyEnum::Two model.enum_enum = MyEnum::Three model.binary_enum = MyEnum::Four model.save.should be_true model.id.should be_a Int64 # Enums model.smallint_enum.should eq MyEnum::Zero model.bigint_enum.should eq MyEnum::One model.string_enum.should eq MyEnum::Two model.enum_enum.should eq MyEnum::Three model.binary_enum.should eq MyEnum::Four # Numeric model.numeric.should eq Math::PI.round(20) # JSON model.string_json.should eq obj model.string_jsonb.should eq obj model.binary_json.should eq obj end end describe "#read" do it "should handle nil values" do model = ConverterModel.new model.save.should be_true model.id.should be_a Int64 retrieved_model = ConverterModel.find! model.id # Enums retrieved_model.smallint_enum.should be_nil retrieved_model.bigint_enum.should be_nil retrieved_model.string_enum.should be_nil retrieved_model.enum_enum.should be_nil retrieved_model.binary_enum.should be_nil # Numeric retrieved_model.numeric.should be_nil # JSON retrieved_model.string_json.should be_nil retrieved_model.string_jsonb.should be_nil retrieved_model.binary_json.should be_nil end it "should handle actual values" do obj = MyType.new model = ConverterModel.new numeric: Math::PI.round(20) model.binary_json = model.string_jsonb = model.string_json = obj model.smallint_enum = MyEnum::Zero model.bigint_enum = MyEnum::One model.string_enum = MyEnum::Two model.enum_enum = MyEnum::Three model.binary_enum = MyEnum::Four model.save.should be_true model.id.should be_a Int64 retrieved_model = ConverterModel.find! model.id # Enum retrieved_model.smallint_enum.should eq MyEnum::Zero retrieved_model.bigint_enum.should eq MyEnum::One retrieved_model.string_enum.should eq MyEnum::Two retrieved_model.enum_enum.should eq MyEnum::Three retrieved_model.binary_enum.should eq MyEnum::Four # Numeric retrieved_model.numeric.should eq Math::PI.round(20) # JSON retrieved_model.string_json.should eq obj retrieved_model.string_jsonb.should eq obj retrieved_model.binary_json.should eq obj end end {% elsif env("CURRENT_ADAPTER") == "sqlite" %} describe "#save" do it "should handle nil values" do model = ConverterModel.new model.save.should be_true model.id.should be_a Int64 # Enums model.int_enum.should be_nil model.string_enum.should be_nil model.binary_enum.should be_nil # JSON model.string_json.should be_nil model.binary_json.should be_nil end it "should handle actual values" do obj = MyType.new model = ConverterModel.new model.binary_json = model.string_json = obj model.int_enum = MyEnum::Zero model.string_enum = MyEnum::Two model.binary_enum = MyEnum::Four model.save.should be_true model.id.should be_a Int64 # Enums model.int_enum.should eq MyEnum::Zero model.string_enum.should eq MyEnum::Two model.binary_enum.should eq MyEnum::Four # JSON model.string_json.should eq obj model.binary_json.should eq obj end end describe "#read" do it "should handle nil values" do model = ConverterModel.new model.save.should be_true model.id.should be_a Int64 retrieved_model = ConverterModel.find! model.id # Enums retrieved_model.int_enum.should be_nil retrieved_model.string_enum.should be_nil retrieved_model.binary_enum.should be_nil # JSON retrieved_model.string_json.should be_nil retrieved_model.binary_json.should be_nil end it "should handle actual values" do obj = MyType.new model = ConverterModel.new model.binary_json = model.string_json = obj model.int_enum = MyEnum::Zero model.string_enum = MyEnum::Two model.binary_enum = MyEnum::Four model.save.should be_true model.id.should be_a Int64 retrieved_model = ConverterModel.find! model.id # Enums retrieved_model.int_enum.should eq MyEnum::Zero retrieved_model.string_enum.should eq MyEnum::Two retrieved_model.binary_enum.should eq MyEnum::Four # JSON retrieved_model.string_json.should eq obj retrieved_model.binary_json.should eq obj end end {% elsif env("CURRENT_ADAPTER") == "mysql" %} describe "#save" do it "should handle nil values" do model = ConverterModel.new model.save.should be_true model.id.should be_a Int64 # Enums model.int_enum.should be_nil model.string_enum.should be_nil model.enum_enum.should be_nil model.binary_enum.should be_nil # JSON model.string_json.should be_nil model.binary_json.should be_nil end it "should handle actual values" do obj = MyType.new model = ConverterModel.new model.binary_json = model.string_json = obj model.int_enum = MyEnum::Zero model.string_enum = MyEnum::Two model.enum_enum = MyEnum::Three model.binary_enum = MyEnum::Four model.save.should be_true model.id.should be_a Int64 # Enums model.int_enum.should eq MyEnum::Zero model.string_enum.should eq MyEnum::Two model.enum_enum.should eq MyEnum::Three model.binary_enum.should eq MyEnum::Four # JSON model.string_json.should eq obj model.binary_json.should eq obj end end describe "#read" do it "should handle nil values" do model = ConverterModel.new model.save.should be_true model.id.should be_a Int64 retrieved_model = ConverterModel.find! model.id # Enums retrieved_model.int_enum.should be_nil retrieved_model.string_enum.should be_nil retrieved_model.enum_enum.should be_nil retrieved_model.binary_enum.should be_nil # JSON retrieved_model.string_json.should be_nil retrieved_model.binary_json.should be_nil end it "should handle actual values" do obj = MyType.new model = ConverterModel.new model.binary_json = model.string_json = obj model.int_enum = MyEnum::Zero model.string_enum = MyEnum::Two model.enum_enum = MyEnum::Three model.binary_enum = MyEnum::Four model.save.should be_true model.id.should be_a Int64 retrieved_model = ConverterModel.find! model.id # Enums retrieved_model.int_enum.should eq MyEnum::Zero retrieved_model.string_enum.should eq MyEnum::Two retrieved_model.enum_enum.should eq MyEnum::Three retrieved_model.binary_enum.should eq MyEnum::Four # JSON retrieved_model.string_json.should eq obj retrieved_model.binary_json.should eq obj end end {% end %} end ================================================ FILE: spec/granite/converters/enum_spec.cr ================================================ require "../../spec_helper" enum TestEnum Zero One Two Three = 17 end describe Granite::Converters::Enum do describe Number do describe ".to_db" do it "should convert a Test enum into a Number" do Granite::Converters::Enum(TestEnum, Int8).to_db(TestEnum::One).should eq 1_i64 Granite::Converters::Enum(TestEnum, Float64).to_db(TestEnum::Two).should eq 2_i64 Granite::Converters::Enum(TestEnum, Int32).to_db(TestEnum::Three).should eq 17_i64 end end describe ".from_rs" do it "should convert the RS value into a Test enum" do rs = FieldEmitter.new.tap do |e| e._set_values([0]) end Granite::Converters::Enum(TestEnum, Int32).from_rs(rs).should eq TestEnum::Zero end it "should convert the RS value into a Test enum" do rs = FieldEmitter.new.tap do |e| e._set_values([1_i16]) end Granite::Converters::Enum(TestEnum, Int16).from_rs(rs).should eq TestEnum::One end it "should convert the RS value into a Test enum" do rs = FieldEmitter.new.tap do |e| e._set_values([17.0]) end Granite::Converters::Enum(TestEnum, Float64).from_rs(rs).should eq TestEnum::Three end end end describe String do describe ".to_db" do it "should convert a Test enum into a string" do Granite::Converters::Enum(TestEnum, String).to_db(TestEnum::Two).should eq "Two" end end describe ".from_rs" do it "should convert the RS value into a Test enum" do rs = FieldEmitter.new.tap do |e| e._set_values(["Three"]) end Granite::Converters::Enum(TestEnum, String).from_rs(rs).should eq TestEnum::Three end end end describe Bytes do describe ".to_db" do it "should convert a Test enum into a string" do Granite::Converters::Enum(TestEnum, Bytes).to_db(TestEnum::Two).should eq "Two" end end describe ".from_rs" do it "should convert an Int32 value into a Test enum" do rs = FieldEmitter.new.tap do |e| e._set_values([Bytes[90, 101, 114, 111]]) end Granite::Converters::Enum(TestEnum, Bytes).from_rs(rs).should eq TestEnum::Zero end end end end ================================================ FILE: spec/granite/converters/json_spec.cr ================================================ require "../../spec_helper" describe Granite::Converters::Json do describe String do describe ".to_db" do it "should convert an Object into a String" do Granite::Converters::Json(MyType, String).to_db(MyType.new).should eq MyType.new.to_json end end describe ".from_rs" do it "should convert the RS value into a MyType" do rs = FieldEmitter.new.tap do |e| e._set_values([MyType.new.to_json]) end Granite::Converters::Json(MyType, String).from_rs(rs).should eq MyType.new end end end describe String do describe ".to_db" do it "should convert an Object into a String" do Granite::Converters::Json(MyType, JSON::Any).to_db(MyType.new).should eq MyType.new.to_json end end describe ".from_rs" do it "should convert the RS value into a MyType" do rs = FieldEmitter.new.tap do |e| e._set_values([JSON.parse(MyType.new.to_json)]) end Granite::Converters::Json(MyType, JSON::Any).from_rs(rs).should eq MyType.new end end end describe Bytes do describe ".to_db" do it "should convert an Object into Bytes" do Granite::Converters::Json(MyType, Bytes).to_db(MyType.new).should eq MyType.new.to_json.to_slice end end describe ".from_rs" do it "should convert the RS value into a MyType" do rs = FieldEmitter.new.tap do |e| e._set_values([ Bytes[123, 34, 110, 97, 109, 101, 34, 58, 34, 74, 105, 109, 34, 44, 34, 97, 103, 101, 34, 58, 49, 50, 125], ]) end Granite::Converters::Json(MyType, Bytes).from_rs(rs).should eq MyType.new end end end end ================================================ FILE: spec/granite/converters/pg_numeric_spec.cr ================================================ require "../../spec_helper" describe Granite::Converters::PgNumeric do describe ".to_db" do it "should convert a Float enum into a Float" do Granite::Converters::PgNumeric.to_db(3.14).should eq 3.14 end end describe ".from_rs" do it "should convert the RS value into a Float64" do rs = FieldEmitter.new.tap do |e| e._set_values([PG::Numeric.new(2_i16, 0_i16, 0_i16, 1_i16, [1_i16, 3000_i16])]) end Granite::Converters::PgNumeric.from_rs(rs).should eq 1.3 end end end ================================================ FILE: spec/granite/error/error_spec.cr ================================================ require "../../spec_helper" describe Granite::Error do it "should convert to json" do Granite::Error.new("field", "error message").to_json.should eq %({"field":"field","message":"error message"}) end end ================================================ FILE: spec/granite/exceptions/record_invalid_spec.cr ================================================ require "../../spec_helper" describe Granite::RecordNotSaved do it "should have a message" do parent = Parent.new parent.save Granite::RecordNotSaved .new(Parent.name, parent) .message .should eq("Could not process Parent: Name cannot be blank") end it "should have a model" do parent = Parent.new parent.save Granite::RecordNotSaved .new(Parent.name, parent) .model .should eq(parent) end end ================================================ FILE: spec/granite/exceptions/record_not_destroyed_spec.cr ================================================ require "../../spec_helper" describe Granite::RecordNotDestroyed do it "should have a message" do parent = Parent.new parent.save Granite::RecordNotDestroyed .new(Parent.name, parent) .message .should eq("Could not destroy Parent: Name cannot be blank") end it "should have a model" do parent = Parent.new parent.save Granite::RecordNotDestroyed .new(Parent.name, parent) .model .should eq(parent) end end ================================================ FILE: spec/granite/integrators/find_or_spec.cr ================================================ require "../../spec_helper" describe "find_or_create_by, find_or_initialize_by" do it "creates on find_or_create when not found" do Parent.clear Parent.find_or_create_by(name: "name") Parent.first!.name.should eq("name") Parent.first!.new_record?.should eq(false) end it "uses find on find_or_create_by when it exists" do Parent.clear Parent.create(name: "name") Parent.find_or_create_by(name: "name") Parent.count.should eq(1) end it "uses find on find_or_initialize_by when it exists" do Parent.clear Parent.create(name: "name") parent = Parent.find_or_initialize_by(name: "name") parent.new_record?.should eq(false) end it "initializes with find_or_initialize when not found" do Parent.clear parent = Parent.find_or_initialize_by(name: "gnome") parent.new_record?.should eq(true) end end ================================================ FILE: spec/granite/migrator/migrator_spec.cr ================================================ require "../../spec_helper" describe Granite::Migrator do describe "#drop_sql" do it "generates correct SQL with #{{{ env("CURRENT_ADAPTER") }}} adapter" do {% if env("CURRENT_ADAPTER") == "mysql" %} Review.migrator.drop_sql.should eq "DROP TABLE IF EXISTS `reviews`;" {% else %} Review.migrator.drop_sql.should eq "DROP TABLE IF EXISTS \"reviews\";" {% end %} end end describe "#create_sql" do it "generates correct SQL with #{{{ env("CURRENT_ADAPTER") }}} adapter" do {% if env("CURRENT_ADAPTER") == "pg" %} Review.migrator.create_sql.should eq <<-SQL CREATE TABLE "reviews"( "id" BIGSERIAL PRIMARY KEY, "name" TEXT , "downvotes" INT , "upvotes" BIGINT , "sentiment" REAL , "interest" DOUBLE PRECISION , "published" BOOL , "created_at" TIMESTAMP ) ;\n SQL Kvs.migrator.create_sql.should eq <<-SQL CREATE TABLE "kvs"( "k" TEXT PRIMARY KEY, "v" TEXT ) ;\n SQL UUIDModel.migrator.create_sql.should eq <<-SQL CREATE TABLE "uuids"( "uuid" UUID PRIMARY KEY) ;\n SQL Character.migrator.create_sql.should eq <<-SQL CREATE TABLE "characters"( "character_id" SERIAL PRIMARY KEY, "name" TEXT NOT NULL ) ;\n SQL # Also check Array types for pg ArrayModel.migrator.create_sql.should eq <<-SQL CREATE TABLE "array_model"( "id" SERIAL PRIMARY KEY, "str_array" TEXT[] , "i16_array" SMALLINT[] , "i32_array" INT[] , "i64_array" BIGINT[] , "f32_array" REAL[] , "f64_array" DOUBLE PRECISION[] , "bool_array" BOOLEAN[] ) ;\n SQL {% elsif env("CURRENT_ADAPTER") == "mysql" %} Review.migrator.create_sql.should eq <<-SQL CREATE TABLE `reviews`( `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, `name` VARCHAR(255) , `downvotes` INT , `upvotes` BIGINT , `sentiment` FLOAT , `interest` DOUBLE , `published` BOOL , `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ) ;\n SQL Kvs.migrator.create_sql.should eq <<-SQL CREATE TABLE `kvs`( `k` VARCHAR(255) PRIMARY KEY, `v` VARCHAR(255) ) ;\n SQL Character.migrator.create_sql.should eq <<-SQL CREATE TABLE `characters`( `character_id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, `name` VARCHAR(255) NOT NULL ) ;\n SQL UUIDModel.migrator.create_sql.should eq <<-SQL CREATE TABLE `uuids`( `uuid` CHAR(36) PRIMARY KEY) ;\n SQL {% elsif env("CURRENT_ADAPTER") == "sqlite" %} Review.migrator.create_sql.should eq <<-SQL CREATE TABLE "reviews"( "id" INTEGER NOT NULL PRIMARY KEY, "name" VARCHAR(255) , "downvotes" INTEGER , "upvotes" INTEGER , "sentiment" FLOAT , "interest" REAL , "published" BOOL , "created_at" VARCHAR ) ;\n SQL Kvs.migrator.create_sql.should eq <<-SQL CREATE TABLE "kvs"( "k" VARCHAR(255) PRIMARY KEY, "v" VARCHAR(255) ) ;\n SQL Character.migrator.create_sql.should eq <<-SQL CREATE TABLE "characters"( "character_id" INTEGER NOT NULL PRIMARY KEY, "name" VARCHAR(255) NOT NULL ) ;\n SQL UUIDModel.migrator.create_sql.should eq <<-SQL CREATE TABLE "uuids"( "uuid" CHAR(36) PRIMARY KEY) ;\n SQL {% end %} end it "supports a manually supplied column type" do {% if env("CURRENT_ADAPTER") == "pg" %} ManualColumnType.migrator.create_sql.should eq <<-SQL CREATE TABLE "manual_column_types"( "id" BIGSERIAL PRIMARY KEY, "foo" DECIMAL(12, 10) ) ;\n SQL {% elsif env("CURRENT_ADAPTER") == "mysql" %} ManualColumnType.migrator.create_sql.should eq <<-SQL CREATE TABLE `manual_column_types`( `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, `foo` DECIMAL(12, 10) ) ;\n SQL {% elsif env("CURRENT_ADAPTER") == "sqlite" %} ManualColumnType.migrator.create_sql.should eq <<-SQL CREATE TABLE "manual_column_types"( "id" INTEGER NOT NULL PRIMARY KEY, "foo" DECIMAL(12, 10) ) ;\n SQL {% end %} end end end ================================================ FILE: spec/granite/query/assemblers/mysql_spec.cr ================================================ require "../spec_helper" {% if env("CURRENT_ADAPTER").id == "mysql" %} describe Granite::Query::Assembler::Mysql(Model) do context "count" do it "counts for where/count queries" do sql = "select count(*) from table where name = ?" builder.where(name: "bob").count.raw_sql.should match ignore_whitespace sql end it "simple counts" do sql = "select count(*) from table" builder.count.raw_sql.should match ignore_whitespace sql end it "adds group_by fields for where/count queries" do sql = "select count(*) from table where name = ? group by name" builder.where(name: "bob").group_by(:name).count.raw_sql.should match ignore_whitespace sql end end context "group_by" do it "adds group_by for select query" do sql = "select #{query_fields} from table group by name order by id desc" builder.group_by(:name).raw_sql.should match ignore_whitespace sql end it "adds multiple group_by for select query" do sql = "select #{query_fields} from table group by name, age order by id desc" builder.group_by([:name, :age]).raw_sql.should match ignore_whitespace sql end it "adds chain of group_by for select query" do sql = "select #{query_fields} from table group by id, name, age order by id desc" builder.group_by(:id).group_by([:name, :age]).raw_sql.should match ignore_whitespace sql end end context "where" do it "properly numbers fields" do sql = "select #{query_fields} from table where name = ? and age = ? order by id desc" query = builder.where(name: "bob", age: "23") query.raw_sql.should match ignore_whitespace sql assembler = query.assembler assembler.where assembler.numbered_parameters.should eq ["bob", "23"] end it "property defines IN query" do sql = "SELECT #{query_fields} FROM table WHERE date_completed IS NULL AND status IN (?,?) ORDER BY id DESC" query = builder.where(date_completed: nil, status: ["outstanding", "in_progress"]) query.raw_sql.should match ignore_whitespace sql assembler = query.assembler assembler.where assembler.numbered_parameters.should eq ["outstanding", "in_progress"] end it "property defines IN query with numbers" do sql = "SELECT #{query_fields} FROM table WHERE date_completed IS NULL AND id IN (1,2) ORDER BY id DESC" query = builder.where(date_completed: nil, id: [1, 2]) query.raw_sql.should match ignore_whitespace sql assembler = query.assembler assembler.where assembler.numbered_parameters.should eq [] of Granite::Columns::Type end it "property defines IN query with booleans" do sql = "SELECT #{query_fields} FROM table WHERE published IN (true,false) ORDER BY id DESC" query = builder.where(published: [true, false]) query.raw_sql.should match ignore_whitespace sql assembler = query.assembler assembler.where assembler.numbered_parameters.should eq [] of Granite::Columns::Type end it "handles raw SQL" do sql = "select #{query_fields} from table where name = 'bob' and age = ? and color = ? order by id desc" query = builder.where("name = 'bob'").where("age = ?", 23).where("color = ?", "red") query.raw_sql.should match ignore_whitespace sql assembler = query.assembler assembler.where assembler.numbered_parameters.should eq [23, "red"] end end context "order" do it "uses default sort when no sort is provided" do builder.raw_sql.should match ignore_whitespace "select #{query_fields} from table order by id desc" end it "uses specified sort when provided" do sql = "select #{query_fields} from table order by id asc" builder.order(id: :asc).raw_sql.should match ignore_whitespace sql end end context "offset" do it "adds offset for select query" do sql = "select #{query_fields} from table order by id desc offset 8" builder.offset(8).raw_sql.should match ignore_whitespace sql end it "adds offset for first query" do sql = "select #{query_fields} from table order by id desc limit 1 offset 3" builder.offset(3).assembler.first.raw_sql.should match ignore_whitespace sql end end context "limit" do it "adds limit for select query" do sql = "select #{query_fields} from table order by id desc limit 5" builder.limit(5).raw_sql.should match ignore_whitespace sql end end end {% end %} ================================================ FILE: spec/granite/query/assemblers/pg_spec.cr ================================================ require "../spec_helper" {% if env("CURRENT_ADAPTER").id == "pg" %} describe Granite::Query::Assembler::Pg(Model) do context "count" do it "counts for where/count queries" do sql = "select count(*) from table where name = $1" builder.where(name: "bob").count.raw_sql.should match ignore_whitespace sql end it "simple counts" do sql = "select count(*) from table" builder.count.raw_sql.should match ignore_whitespace sql end it "adds group_by fields for where/count queries" do sql = "select count(*) from table where name = $1 group by name" builder.where(name: "bob").group_by(:name).count.raw_sql.should match ignore_whitespace sql end end context "group_by" do it "adds group_by for select query" do sql = "select #{query_fields} from table group by name order by id desc" builder.group_by(:name).raw_sql.should match ignore_whitespace sql end it "adds multiple group_by for select query" do sql = "select #{query_fields} from table group by name, age order by id desc" builder.group_by([:name, :age]).raw_sql.should match ignore_whitespace sql end it "adds chain of group_by for select query" do sql = "select #{query_fields} from table group by id, name, age order by id desc" builder.group_by(:id).group_by([:name, :age]).raw_sql.should match ignore_whitespace sql end end context "where" do it "properly numbers fields" do sql = "select #{query_fields} from table where name = $1 and age = $2 order by id desc" query = builder.where(name: "bob", age: "23") query.raw_sql.should match ignore_whitespace sql assembler = query.assembler assembler.where assembler.numbered_parameters.should eq ["bob", "23"] end it "property defines IN query" do sql = "SELECT #{query_fields} FROM table WHERE date_completed IS NULL AND status IN ($1,$2) ORDER BY id DESC" query = builder.where(date_completed: nil, status: ["outstanding", "in_progress"]) query.raw_sql.should match ignore_whitespace sql assembler = query.assembler assembler.where assembler.numbered_parameters.should eq ["outstanding", "in_progress"] end it "property defines IN query with numbers" do sql = "SELECT #{query_fields} FROM table WHERE date_completed IS NULL AND id IN (1,2) ORDER BY id DESC" query = builder.where(date_completed: nil, id: [1, 2]) query.raw_sql.should match ignore_whitespace sql assembler = query.assembler assembler.where assembler.numbered_parameters.should eq [] of Granite::Columns::Type end it "property defines IN query with booleans" do sql = "SELECT #{query_fields} FROM table WHERE published IN (true,false) ORDER BY id DESC" query = builder.where(published: [true, false]) query.raw_sql.should match ignore_whitespace sql assembler = query.assembler assembler.where assembler.numbered_parameters.should eq [] of Granite::Columns::Type end it "handles raw SQL" do sql = "select #{query_fields} from table where name = 'bob' and age = $1 and color = $2 order by id desc" query = builder.where("name = 'bob'").where("age = $", 23).where("color = $", "red") query.raw_sql.should match ignore_whitespace sql assembler = query.assembler assembler.where assembler.numbered_parameters.should eq [23, "red"] end end context "order" do it "uses default sort when no sort is provided" do builder.raw_sql.should match ignore_whitespace "select #{query_fields} from table order by id desc" end it "uses specified sort when provided" do sql = "select #{query_fields} from table order by id asc" builder.order(id: :asc).raw_sql.should match ignore_whitespace sql end end context "offset" do it "adds offset for select query" do sql = "select #{query_fields} from table order by id desc offset 8" builder.offset(8).raw_sql.should match ignore_whitespace sql end it "adds offset for first query" do sql = "select #{query_fields} from table order by id desc limit 1 offset 3" builder.offset(3).assembler.first.raw_sql.should match ignore_whitespace sql end end context "limit" do it "adds limit for select query" do sql = "select #{query_fields} from table order by id desc limit 5" builder.limit(5).raw_sql.should match ignore_whitespace sql end end end {% end %} ================================================ FILE: spec/granite/query/assemblers/sqlite_spec.cr ================================================ require "../spec_helper" {% if env("CURRENT_ADAPTER").id == "sqlite" %} describe Granite::Query::Assembler::Sqlite(Model) do context "count" do it "counts for where/count queries" do sql = "select count(*) from table where name = ?" builder.where(name: "bob").count.raw_sql.should match ignore_whitespace sql end it "simple counts" do sql = "select count(*) from table" builder.count.raw_sql.should match ignore_whitespace sql end it "adds group_by fields for where/count queries" do sql = "select count(*) from table where name = ? group by name" builder.where(name: "bob").group_by(:name).count.raw_sql.should match ignore_whitespace sql end end context "group_by" do it "adds group_by for select query" do sql = "select #{query_fields} from table group by name order by id desc" builder.group_by(:name).raw_sql.should match ignore_whitespace sql end it "adds multiple group_by for select query" do sql = "select #{query_fields} from table group by name, age order by id desc" builder.group_by([:name, :age]).raw_sql.should match ignore_whitespace sql end it "adds chain of group_by for select query" do sql = "select #{query_fields} from table group by id, name, age order by id desc" builder.group_by(:id).group_by([:name, :age]).raw_sql.should match ignore_whitespace sql end end context "where" do it "properly numbers fields" do sql = "select #{query_fields} from table where name = ? and age = ? order by id desc" query = builder.where(name: "bob", age: "23") query.raw_sql.should match ignore_whitespace sql assembler = query.assembler assembler.where assembler.numbered_parameters.should eq ["bob", "23"] end it "property defines IN query" do sql = "SELECT #{query_fields} FROM table WHERE date_completed IS NULL AND status IN (?,?) ORDER BY id DESC" query = builder.where(date_completed: nil, status: ["outstanding", "in_progress"]) query.raw_sql.should match ignore_whitespace sql assembler = query.assembler assembler.where assembler.numbered_parameters.should eq ["outstanding", "in_progress"] end it "property defines IN query with numbers" do sql = "SELECT #{query_fields} FROM table WHERE date_completed IS NULL AND id IN (1,2) ORDER BY id DESC" query = builder.where(date_completed: nil, id: [1, 2]) query.raw_sql.should match ignore_whitespace sql assembler = query.assembler assembler.where assembler.numbered_parameters.should eq [] of Granite::Columns::Type end it "property defines IN query with booleans" do sql = "SELECT #{query_fields} FROM table WHERE published IN (true,false) ORDER BY id DESC" query = builder.where(published: [true, false]) query.raw_sql.should match ignore_whitespace sql assembler = query.assembler assembler.where assembler.numbered_parameters.should eq [] of Granite::Columns::Type end it "handles raw SQL" do sql = "select #{query_fields} from table where name = 'bob' and age = ? and color = ? order by id desc" query = builder.where("name = 'bob'").where("age = ?", 23).where("color = ?", "red") query.raw_sql.should match ignore_whitespace sql assembler = query.assembler assembler.where assembler.numbered_parameters.should eq [23, "red"] end end context "order" do it "uses default sort when no sort is provided" do builder.raw_sql.should match ignore_whitespace "select #{query_fields} from table order by id desc" end it "uses specified sort when provided" do sql = "select #{query_fields} from table order by id asc" builder.order(id: :asc).raw_sql.should match ignore_whitespace sql end end context "offset" do it "adds offset for select query" do sql = "select #{query_fields} from table order by id desc offset 8" builder.offset(8).raw_sql.should match ignore_whitespace sql end it "adds offset for first query" do sql = "select #{query_fields} from table order by id desc limit 1 offset 3" builder.offset(3).assembler.first.raw_sql.should match ignore_whitespace sql end end context "limit" do it "adds limit for select query" do sql = "select #{query_fields} from table order by id desc limit 5" builder.limit(5).raw_sql.should match ignore_whitespace sql end end end {% end %} ================================================ FILE: spec/granite/query/builder_spec.cr ================================================ require "./spec_helper" describe Granite::Query::Builder(Model) do it "stores where_fields" do query = builder.where(name: "bob").where(age: 23) expected = [{join: :and, field: "name", operator: :eq, value: "bob"}, {join: :and, field: "age", operator: :eq, value: 23}] query.where_fields.should eq expected end it "stores operators with where_fields" do query = builder.where(:name, :like, "bob*").where(:age, :gt, 23) expected = [{join: :and, field: "name", operator: :like, value: "bob*"}, {join: :and, field: "age", operator: :gt, value: 23}] query.where_fields.should eq expected end it "stores joins with where_fields" do query = builder.where(:name, :like, "bob*").or(:age, :gt, 23) expected = [{join: :and, field: "name", operator: :like, value: "bob*"}, {join: :or, field: "age", operator: :gt, value: 23}] query.where_fields.should eq expected end it "stores order fields" do query = builder.order(name: :desc).order(age: :asc) expected = [ {field: "name", direction: Granite::Query::Builder::Sort::Descending}, {field: "age", direction: Granite::Query::Builder::Sort::Ascending}, ] query.order_fields.should eq expected end it "maps array to :in" do query = builder.where(date_completed: nil, status: ["outstanding", "in_progress"]) expected = [ {join: :and, field: "date_completed", operator: :eq, value: nil}, {join: :and, field: "status", operator: :in, value: ["outstanding", "in_progress"]}, ] query.where_fields.should eq expected end it "stores limit" do query = builder.limit(7) query.limit.should eq 7 end it "stores offset" do query = builder.offset(17) query.offset.should eq 17 end context "raw SQL builder" do placeholders = { Granite::Query::Builder::DbType::Mysql => "?", Granite::Query::Builder::DbType::Sqlite => "?", Granite::Query::Builder::DbType::Pg => "$", } it "chains where statements" do placeholder = placeholders[builder.db_type] query = builder.where("name = #{placeholder}", "bob").where("age = #{placeholder}", 23) expected = [{join: :and, stmt: "name = #{placeholder}", value: "bob"}, {join: :and, stmt: "age = #{placeholder}", value: 23}] query.where_fields.should eq expected end it "chains and statements" do placeholder = placeholders[builder.db_type] query = builder.where("name = #{placeholder}", "bob").and("age = #{placeholder}", 23) expected = [{join: :and, stmt: "name = #{placeholder}", value: "bob"}, {join: :and, stmt: "age = #{placeholder}", value: 23}] query.where_fields.should eq expected end it "chains or statements" do placeholder = placeholders[builder.db_type] query = builder.where("name = #{placeholder}", "bob").or("age = #{placeholder}", 23) expected = [{join: :and, stmt: "name = #{placeholder}", value: "bob"}, {join: :or, stmt: "age = #{placeholder}", value: 23}] query.where_fields.should eq expected end end end ================================================ FILE: spec/granite/query/executor_spec.cr ================================================ # default value when a Value query is run # delegates properly ================================================ FILE: spec/granite/query/spec_helper.cr ================================================ require "spec" require "db" require "../../../src/granite/query/builder" class Model def self.table_name "table" end def self.fields ["name", "age"] end def self.primary_name "id" end end def query_fields Model.fields.join ", " end def builder {% if env("CURRENT_ADAPTER").id == "pg" %} Granite::Query::Builder(Model).new Granite::Query::Builder::DbType::Pg {% elsif env("CURRENT_ADAPTER").id == "mysql" %} Granite::Query::Builder(Model).new Granite::Query::Builder::DbType::Mysql {% else %} Granite::Query::Builder(Model).new Granite::Query::Builder::DbType::Sqlite {% end %} end def ignore_whitespace(expected : String) whitespace = "\\s+?" compiled = expected.split(/\s/).map { |s| Regex.escape s }.join(whitespace) Regex.new "^\\s*#{compiled}\\s*$", Regex::Options::IGNORE_CASE ^ Regex::Options::MULTILINE end ================================================ FILE: spec/granite/querying/all_spec.cr ================================================ require "../../spec_helper" describe "#all" do it "finds all the records" do Parent.clear model_ids = (0...100).map do |i| Parent.new(name: "model_#{i}").tap(&.save) end.map(&.id) all = Parent.all all.size.should eq model_ids.size all.compact_map(&.id).sort!.should eq model_ids.compact end # TODO Fails under MySQL # it "finds records with numbered query substition" do # name = "findable model" # model = Parent.new(name: name).tap(&.save) # set = Parent.all("WHERE name = $1", [name]) # set.size.should eq 1 # set.first.name.should eq name # end it "finds records with question mark substition" do name = "findable model" Parent.new(name: name).save set = Parent.all("WHERE name = ?", [name]) set.size.should eq 1 set.first.name.should eq name end end ================================================ FILE: spec/granite/querying/count_spec.cr ================================================ require "../../spec_helper" describe "#count" do it "returns 0 if no result" do Parent.clear count = Parent.count count.should eq 0 end it "returns a number of the all records for the model" do count = Parent.count 2.times do |i| Parent.new(name: "model_#{i}").tap(&.save) end (Parent.count - count).should eq 2 end end ================================================ FILE: spec/granite/querying/exists_spec.cr ================================================ require "../../spec_helper" describe ".exists?" do before_each do Parent.clear end describe "when there is a record with that ID" do describe "with a numeric PK" do it "should return true" do model = Parent.new(name: "Some Name") model.save.should be_true Parent.exists?(model.id).should be_true end end describe "with a string PK" do it "should return true" do Kvs.new(k: "EXISTS_ID").save.should be_true Kvs.exists?("EXISTS_ID").should be_true end end describe "with a namedtuple of args" do it "should return true" do model = Parent.new(name: "Some Name") model.save.should be_true Parent.exists?(name: "Some Name", id: model.id).should be_true end end describe "with a hash of args" do it "should return true" do model = Parent.new(name: "Some Name") model.save.should be_true Parent.exists?({:name => "Some Name", "id" => model.id}).should be_true end end describe "with a nil value" do it "should return true" do model = Student.new model.save.should be_true Student.exists?(name: nil, id: model.id).should be_true end end end describe "when there is not a record with that ID" do describe "with a numeric PK" do it "should return false" do Parent.exists?(234567).should be_false end end describe "with a string PK" do it "should return false" do Kvs.exists?("SOME_KEY").should be_false end end describe "with a namedtuple of args" do it "should return false" do model = Parent.new(name: "Some Name") model.save.should be_true Parent.exists?(name: "Some Other Name", id: model.id).should be_false end end describe "with a hash of args" do it "should return false" do model = Parent.new(name: "Some Name") model.save.should be_true Parent.exists?({:name => "Some Other Name", "id" => model.id}).should be_false end end describe "with a nil value" do it "should return false" do model = Student.new(name: "Jim") model.save.should be_true Student.exists?(name: nil, id: model.id).should be_false end end end end ================================================ FILE: spec/granite/querying/find_by_spec.cr ================================================ require "../../spec_helper" describe "#find_by, #find_by!" do it "finds an object with a string field" do Parent.clear name = "robinson" model = Parent.new(name: name) model.save.should be_true found = Parent.find_by(name: name) if pa = found pa.id.should eq model.id else pa.should_not be_nil end found = Parent.find_by!(name: name) found.should be_a(Parent) end it "works with multiple arguments" do Review.clear Review.create(name: "review1", upvotes: 2.to_i64) Review.create(name: "review2", upvotes: 0.to_i64) expected = Review.create(name: "review3", upvotes: 10.to_i64) r = Review.find_by(name: "review3", upvotes: 10) if r r.id.should eq expected.id else r.should_not be_nil end expect_raises(Granite::Querying::NotFound, /No .*Review.* found where name = review1 and upvotes = 20/) do Review.find_by!(name: "review1", upvotes: 20) end end it "finds an object with nil value" do Student.clear model = Student.new model.save.should be_true found = Student.find_by(name: nil) if stu = found stu.id.should eq model.id else stu.should_not be_nil end found = Student.find_by!(name: nil) found.should be_a(Student) end it "works with reserved words" do Parent.clear value = "robinson" model = ReservedWord.new model.all = value model.save.should be_true found = ReservedWord.find_by(all: value) if rw = found rw.id.should eq model.id else rw.should_not be_nil end found = ReservedWord.find_by!(all: value) found.id.should eq model.id end it "finds an object when provided a hash" do Parent.clear name = "johnson" model = Parent.new(name: name) model.save.should be_true found = Parent.find_by({"name" => name}) if pa = found pa.id.should eq model.id else pa.should_not be_nil end found = Parent.find_by!({"name" => name}) found.should be_a(Parent) end it "returns nil or raises if no result" do Parent.clear found = Parent.find_by(name: "xxx") found.should be_nil expect_raises(Granite::Querying::NotFound, /No .*Parent.* found where name = xxx/) do Parent.find_by!(name: "xxx") end end end ================================================ FILE: spec/granite/querying/find_each_spec.cr ================================================ require "../../spec_helper" describe "#find_each" do it "finds all the records" do Parent.clear model_ids = (0...100).map do |i| Parent.new(name: "role_#{i}").tap(&.save) end.map(&.id) found_roles = [] of Int64 | Nil Parent.find_each do |model| found_roles << model.id end found_roles.compact.sort!.should eq model_ids.compact end it "doesnt yield when no records are found" do Parent.clear Parent.find_each do fail "did yield" end end it "can start from an offset" do Parent.clear created_models = (0...10).map do |i| Parent.new(name: "model_#{i}").tap(&.save) end.map(&.id) # discard the first two models created_models.shift created_models.shift found_models = [] of Int64 | Nil Parent.find_each(offset: 2) do |model| found_models << model.id end found_models.compact.sort!.should eq created_models.compact end it "doesnt obliterate a parameterized query" do Parent.clear created_models = (0...10).map do |i| Parent.new(name: "model_#{i}").tap(&.save) end.map(&.id) looking_for_ids = created_models[0...5] found_models = [] of Int64 | Nil Parent.find_each("WHERE id IN(#{looking_for_ids.join(",")})") do |model| found_models << model.id end found_models.compact.should eq looking_for_ids end end ================================================ FILE: spec/granite/querying/find_in_batches.cr ================================================ require "../../spec_helper" describe "#find_in_batches" do it "finds records in batches and yields all the records" do model_ids = (0...100).map do |i| Parent.new(name: "model_#{i}").tap(&.save) end.map(&.id) found_models = [] of Int32 | Nil Parent.find_in_batches(batch_size: 10) do |batch| batch.each { |model| found_models << model.id } batch.size.should eq 10 end found_models.compact.sort!.should eq model_ids.compact end it "doesnt yield when no records are found" do Parent.find_in_batches do fail "find_in_batches did yield but shouldn't have" end end it "errors when batch_size is < 1" do expect_raises ArgumentError do Parent.find_in_batches batch_size: 0 do fail "should have raised" end end end it "returns a small batch when there arent enough results" do (0...9).each do |i| Parent.new(name: "model_#{i}").save end Parent.find_in_batches(batch_size: 11) do |batch| batch.size.should eq 9 end end it "can start from an offset other than 0" do created_models = (0...10).map do |i| Parent.new(name: "model_#{i}").tap(&.save) end.map(&.id) # discard the first two models created_models.shift created_models.shift found_models = [] of Int32 | Nil Parent.find_in_batches(offset: 2) do |batch| batch.each do |model| found_models << model.id end end found_models.compact.sort!.should eq created_models.compact end it "doesnt obliterate a parameterized query" do created_models = (0...10).map do |i| Parent.new(name: "model_#{i}").tap(&.save) end.map(&.id) looking_for_ids = created_models[0...5] Parent.find_in_batches("WHERE id IN(#{looking_for_ids.join(",")})") do |batch| batch.compact_map(&.id).should eq looking_for_ids end end end ================================================ FILE: spec/granite/querying/find_spec.cr ================================================ require "../../spec_helper" describe "#find, #find!" do it "finds an object by id" do model = Parent.new model.name = "Test Comment" model.save found = Parent.find model.id found.should_not be_nil found && (found.id.should eq model.id) found = Parent.find! model.id found.id.should eq model.id end it "updates states of new_record and persisted" do model = Parent.new model.name = "Test Comment" model.save model_id = model.id model = Parent.find!(model_id) model.new_record?.should be_false model.persisted?.should be_true end describe "with a custom primary key" do it "finds the object" do school = School.new school.name = "Test School" school.save primary_key = school.custom_id found_school = School.find primary_key found_school.should_not be_nil found_school = School.find! primary_key found_school.should be_a(School) end end describe "with a modulized model" do it "finds the object" do county = Nation::County.new county.name = "Test County" county.save primary_key = county.id found_county = Nation::County.find primary_key found_county.should_not be_nil found_county = Nation::County.find! primary_key found_county.should be_a(Nation::County) end end it "returns nil or raises if no result" do found = Parent.find 0 found.should be_nil expect_raises(Granite::Querying::NotFound, /No .*Parent.* found where id = 0/) do Parent.find! 0 end end end ================================================ FILE: spec/granite/querying/first_spec.cr ================================================ require "../../spec_helper" describe "#first, #first!" do it "finds the first object" do Parent.clear first = Parent.new.tap do |model| model.name = "Test 1" model.save end Parent.new.tap do |model| model.name = "Test 2" model.save end found = Parent.first if pa = found pa.id.should eq first.id else pa.should_not be_nil end found = Parent.first! found.id.should eq first.id end it "supports a SQL clause" do Parent.clear Parent.new.tap do |model| model.name = "Test 1" model.save end second = Parent.new.tap do |model| model.name = "Test 2" model.save end found = Parent.first("ORDER BY id DESC") if pa = found found.id.should eq second.id else pa.should_not be_nil end found = Parent.first!("ORDER BY id DESC") found.id.should eq second.id end it "returns nil or raises if no result" do Parent.clear Parent.new.tap do |model| model.name = "Test 1" model.save end found = Parent.first("WHERE name = 'Test 2'") found.should be nil expect_raises(Granite::Querying::NotFound, /No .*Parent.* found with first\(WHERE name = 'Test 2'\)/) do Parent.first!("WHERE name = 'Test 2'") end end end ================================================ FILE: spec/granite/querying/from_rs_spec.cr ================================================ require "../../spec_helper" macro build_review_emitter FieldEmitter.new.tap do |e| e._set_values( [ 8_i64, "name", nil, # downvotes nil, # upvotes nil, # sentiment nil, # interest true, # published Time.local, # created_at ] ) end end def method_which_takes_any_model(model : Granite::Base.class) model.as(Granite::Base).from_rs build_review_emitter end describe ".from_rs" do it "Builds a model from a resultset" do model = Review.from_rs build_review_emitter model.class.should eq Review end end ================================================ FILE: spec/granite/querying/passthrough_spec.cr ================================================ require "../../spec_helper" describe "#query" do it "calls query against the db driver" do Parent.clear Parent.query "SELECT name FROM parents" do |rs| rs.column_name(0).should eq "name" rs.close end end end describe "#scalar" do it "calls scalar against the db driver" do Parent.clear Parent.scalar "SELECT count(*) FROM parents" do |total| total.should eq 0 end end end ================================================ FILE: spec/granite/querying/query_builder_spec.cr ================================================ require "../../spec_helper" describe Granite::Query::BuilderMethods do describe "#where" do describe "with array arguments" do it "correctly queries all rows with a list of id values" do review1 = Review.create(name: "one") review2 = Review.create(name: "two") found = Review.where(id: [review1.id, review2.id]).select found[0].id.should eq review2.id found[1].id.should eq review1.id end it "correctly queries all rows with a list of id values and names" do review1 = Review.create(name: "one") review2 = Review.create(name: "two") found = Review.where(name: ["one", "two"]).and(id: [review1.id, review2.id]).select found[0].id.should eq review2.id found[1].id.should eq review1.id end it "correctly queries all rows with a list of id values or names" do review1 = Review.create(name: "one") review2 = Review.create(name: "two") found = Review.where(id: [1001, 1002]).or(name: ["one", "two"]).select found[0].id.should eq review2.id found[1].id.should eq review1.id found = Review.where(name: ["one", "two"]).or(id: [1001, 1002]).select found[0].id.should eq review2.id found[1].id.should eq review1.id end it "correctly queries with ids fields which doest exists" do Review.create(name: "one") Review.create(name: "two") found = Review.where(id: [1001, 1002]).select found.size.should eq 0 end it "correctly queries string fields" do review1 = Review.create(name: "one") review2 = Review.create(name: "two") found = Review.where(name: ["one", "two"]).select found[0].id.should eq review2.id found[1].id.should eq review1.id end it "correctly queries number fields" do Review.clear review1 = Review.create(name: "one", downvotes: 99) review2 = Review.create(name: "two", downvotes: -4) found = Review.where(downvotes: [99, -4]).select found[0].id.should eq review2.id found[1].id.should eq review1.id end # Sqlite doesnt have bool literals {% if env("CURRENT_ADAPTER") == "sqlite" %} it "correctly queries bool fields" do Review.clear Review.create(name: "one", published: 1) review2 = Review.create(name: "two", published: 0) found = Review.where(published: [0]).select found.size.should eq 1 found[0].id.should eq review2.id end {% else %} it "correctly queries bool fields" do Review.clear Review.create(name: "one", published: true) review2 = Review.create(name: "two", published: false) found = Review.where(published: [false]).select found.size.should eq 1 found[0].id.should eq review2.id end {% end %} end end describe "#exists?" do describe "when there is a record with that ID" do describe "when querying on the PK" do it "should return true" do model = Parent.new(name: "Some Name") model.save.should be_true Parent.where(id: model.id).exists?.should be_true end end describe "with multiple args" do it "should return true" do model = Parent.new(name: "Some Name") model.save.should be_true Parent.where(name: "Some Name", id: model.id).exists?.should be_true end end end describe "when there is not a record with that ID" do describe "when querying on the PK" do it "should return false" do Parent.where(id: 234567).exists?.should be_false end end describe "with multiple args" do it "should return false" do model = Parent.new(name: "Some Name") model.save.should be_true Parent.where(name: "Some Other Name", id: model.id).exists?.should be_false end end end end end ================================================ FILE: spec/granite/querying/reload_spec.cr ================================================ require "../../spec_helper" describe "#reload" do before_each do Parent.clear end it "reloads the record from the database" do parent = Parent.create(name: "Parent") Parent.find!(parent.id).update(name: "Other") parent.reload.name.should eq "Other" end it "raises an error if the record no longer exists" do parent = Parent.create(name: "Parent") parent.destroy expect_raises(Granite::Querying::NotFound) do parent.reload end end end ================================================ FILE: spec/granite/select/select_spec.cr ================================================ require "../../spec_helper" describe "custom select" do before_each do Article.clear Comment.clear EventCon.clear end it "generates custom SQL with the query macro" do ArticleViewModel.select.should eq "SELECT articles.id, articles.articlebody, comments.commentbody FROM articles JOIN comments ON comments.articleid = articles.id" end it "uses custom SQL to populate a view model - #all" do first = Article.new.tap do |model| model.articlebody = "The Article Body" model.save! end Comment.new.tap do |model| model.commentbody = "The Comment Body" model.articleid = first.id model.save! end viewmodel = ArticleViewModel.all viewmodel.first.articlebody.should eq "The Article Body" viewmodel.first.commentbody.should eq "The Comment Body" end it "allow only selecting specific columns" do EventCon.create(con_name: "Con0", event_name: "Event0") EventCon.create(con_name: "Con1", event_name: "Event1") EventCon.create(con_name: "Con2", event_name: "Event2") EventCon.all.each_with_index do |env, idx| env.id.should be_nil env.con_name.should eq "Con#{idx}" env.event_name.should be_nil end end # TODO: `find` on this ViewModel fails because "id" is ambiguous in a complex SELECT. # it "uses custom SQL to populate a view model - #find" do # first = Article.new.tap do |model| # model.articlebody = "The Article Body" # model.save # end # second = Comment.new.tap do |model| # model.commentbody = "The Comment Body" # model.articleid = first.id # model.save # end # viewmodel = ArticleViewModel.find!(first.id) # viewmodel.articlebody.should eq "The Article Body" # viewmodel.commentbody.should eq "The Comment Body" # end end ================================================ FILE: spec/granite/table/table_spec.cr ================================================ require "../../spec_helper" describe Granite::Table do describe ".table_name" do it "sets the table name to name specified" do CustomSongThread.table_name.should eq "custom_table_name" end it "sets the table name based on class name if not specified" do SongThread.table_name.should eq "song_thread" end it "strips the namespace when defining the default table now" do MyApp::Namespace::Model.table_name.should eq "model" end end describe ".primary_name" do it "sets the primary key name to name specified" do CustomSongThread.primary_name.should eq "custom_primary_key" end it "sets the primary key name to id if not specified" do SongThread.primary_name.should eq "id" end end end ================================================ FILE: spec/granite/transactions/create_spec.cr ================================================ require "../../spec_helper" describe "#create" do it "creates a new object" do parent = Parent.create(name: "Test Parent") parent.persisted?.should be_true parent.name.should eq("Test Parent") end it "does not create an invalid object" do parent = Parent.create(name: "") parent.persisted?.should be_false end describe "with a custom primary key" do it "creates a new object" do school = School.create(name: "Test School") school.persisted?.should be_true school.name.should eq("Test School") end end describe "with a modulized model" do it "creates a new object" do county = Nation::County.create(name: "Test School") county.persisted?.should be_true county.name.should eq("Test School") end end describe "using a reserved word as a column name" do it "creates a new object" do reserved_word = ReservedWord.create(all: "foo") reserved_word.errors.empty?.should be_true reserved_word.all.should eq("foo") end end context "when skip_timestamps is true" do it "does not update the created_at & updated_at fields" do parent = Parent.create({name: "new parent"}, skip_timestamps: true) Parent.find!(parent.id).created_at.should be_nil Parent.find!(parent.id).updated_at.should be_nil end end end describe "#create!" do it "creates a new object" do parent = Parent.create!(name: "Test Parent") parent.persisted?.should be_true parent.name.should eq("Test Parent") end it "does not save but raise an exception" do expect_raises(Granite::RecordNotSaved, "Parent") do Parent.create!(name: "") end end context "when skip_timestamps is true" do it "does not update the created_at & updated_at fields" do parent = Parent.create!({name: "new parent"}, skip_timestamps: true) Parent.find!(parent.id).created_at.should be_nil Parent.find!(parent.id).updated_at.should be_nil end end end ================================================ FILE: spec/granite/transactions/destroy_spec.cr ================================================ require "../../spec_helper" describe "#destroy" do it "destroys an object" do parent = Parent.new parent.name = "Test Parent" parent.save id = parent.id parent.destroy found = Parent.find id found.should be_nil end it "updates states of destroyed and persisted" do parent = Parent.new parent.destroyed?.should be_false parent.persisted?.should be_false parent.name = "Test Parent" parent.save parent.destroyed?.should be_false parent.persisted?.should be_true parent.destroy parent.destroyed?.should be_true parent.persisted?.should be_false end describe "with a custom primary key" do it "destroys an object" do school = School.new school.name = "Test School" school.save primary_key = school.custom_id school.destroy found_school = School.find primary_key found_school.should be_nil end end describe "with a modulized model" do it "destroys an object" do county = Nation::County.new county.name = "Test County" county.save primary_key = county.id county.destroy found_county = Nation::County.find primary_key found_county.should be_nil end end end describe "#destroy!" do it "destroys an object" do parent = Parent.new parent.name = "Test Parent" parent.save! id = parent.id parent.destroy found = Parent.find id found.should be_nil end it "does not destroy but raise an exception" do callback_with_abort = CallbackWithAbort.new callback_with_abort.name = "DestroyRaisesException" callback_with_abort.abort_at = "temp" callback_with_abort.do_abort = false callback_with_abort.save! callback_with_abort.abort_at = "before_destroy" callback_with_abort.do_abort = true expect_raises(Granite::RecordNotDestroyed, "CallbackWithAbort") do callback_with_abort.destroy! end CallbackWithAbort.find_by(name: callback_with_abort.name).should_not be_nil end end ================================================ FILE: spec/granite/transactions/import_spec.cr ================================================ require "../../spec_helper" describe "#import" do describe "using the defualt primary key" do context "with an AUTO INCREMENT PK" do it "should import 3 new objects" do Parent.clear to_import = [ Parent.new(name: "ImportParent1"), Parent.new(name: "ImportParent2"), Parent.new(name: "ImportParent3"), ] Parent.import(to_import) Parent.all("WHERE name LIKE ?", ["ImportParent%"]).size.should eq 3 end it "should work with batch_size" do to_import = [ Book.new(name: "ImportBatchBook1"), Book.new(name: "ImportBatchBook2"), Book.new(name: "ImportBatchBook3"), Book.new(name: "ImportBatchBook4"), ] Book.import(to_import, batch_size: 2) Book.all("WHERE name LIKE ?", ["ImportBatch%"]).size.should eq 4 end it "should be able to update existing records" do to_import = [ Review.new(name: "ImportReview1", published: false, upvotes: 0.to_i64), Review.new(name: "ImportReview2", published: false, upvotes: 0.to_i64), Review.new(name: "ImportReview3", published: false, upvotes: 0.to_i64), Review.new(name: "ImportReview4", published: false, upvotes: 0.to_i64), ] Review.import(to_import) reviews = Review.all("WHERE name LIKE ?", ["ImportReview%"]) reviews.size.should eq 4 reviews.none?(&.published).should be_true reviews.all? { |r| r.upvotes == 0 }.should be_true reviews.each { |r| r.published = true; r.upvotes = 1.to_i64 } Review.import(reviews, update_on_duplicate: true, columns: ["published", "upvotes"]) reviews = Review.all("WHERE name LIKE ?", ["ImportReview%"]) reviews.size.should eq 4 reviews.all?(&.published).should be_true reviews.all? { |r| r.upvotes == 1 }.should be_true end end context "with non AUTO INCREMENT PK" do it "should work with on_duplicate_key_update" do to_import = [ NonAutoDefaultPK.new(id: 1.to_i64, name: "NonAutoDefaultPK1"), NonAutoDefaultPK.new(id: 2.to_i64, name: "NonAutoDefaultPK2"), NonAutoDefaultPK.new(id: 3.to_i64, name: "NonAutoDefaultPK3"), ] NonAutoDefaultPK.import(to_import) to_import = [ NonAutoDefaultPK.new(id: 3.to_i64, name: "NonAutoDefaultPK3"), ] NonAutoDefaultPK.import(to_import, update_on_duplicate: true, columns: ["name"]) record = NonAutoDefaultPK.find! 3.to_i64 record.name.should eq "NonAutoDefaultPK3" record.id.should eq 3.to_i64 end it "should work with on_duplicate_key_ignore" do to_import = [ NonAutoDefaultPK.new(id: 4.to_i64, name: "NonAutoDefaultPK4"), NonAutoDefaultPK.new(id: 5.to_i64, name: "NonAutoDefaultPK5"), NonAutoDefaultPK.new(id: 6.to_i64, name: "NonAutoDefaultPK6"), ] NonAutoDefaultPK.import(to_import) to_import = [ NonAutoDefaultPK.new(id: 6.to_i64, name: "NonAutoDefaultPK6"), ] NonAutoDefaultPK.import(to_import, ignore_on_duplicate: true) record = NonAutoDefaultPK.find! 6.to_i64 record.name.should eq "NonAutoDefaultPK6" record.id.should eq 6.to_i64 end end end describe "using a custom primary key" do context "with an AUTO INCREMENT PK" do it "should import 3 new objects" do to_import = [ School.new(name: "ImportBasicSchool1"), School.new(name: "ImportBasicSchool2"), School.new(name: "ImportBasicSchool3"), ] School.import(to_import) School.all("WHERE name LIKE ?", ["ImportBasicSchool%"]).size.should eq 3 end it "should work with batch_size" do to_import = [ School.new(name: "ImportBatchSchool1"), School.new(name: "ImportBatchSchool2"), School.new(name: "ImportBatchSchool3"), School.new(name: "ImportBatchSchool4"), ] School.import(to_import, batch_size: 2) School.all("WHERE name LIKE ?", ["ImportBatchSchool%"]).size.should eq 4 end it "should be able to update existing records" do to_import = [ School.new(name: "ImportExistingSchool"), School.new(name: "ImportExistingSchool"), School.new(name: "ImportExistingSchool"), School.new(name: "ImportExistingSchool"), ] School.import(to_import) schools = School.all("WHERE name = ?", ["ImportExistingSchool"]) schools.size.should eq 4 schools.all? { |s| s.name == "ImportExistingSchool" }.should be_true schools.each(&.name=("ImportExistingSchoolEdited")) School.import(schools, update_on_duplicate: true, columns: ["name"]) schools = School.all("WHERE name LIKE ?", ["ImportExistingSchool%"]) schools.size.should eq 4 schools.all? { |s| s.name == "ImportExistingSchoolEdited" }.should be_true end end context "with non AUTO INCREMENT PK" do it "should work with on_duplicate_key_update" do to_import = [ NonAutoCustomPK.new(custom_id: 1.to_i64, name: "NonAutoCustomPK1"), NonAutoCustomPK.new(custom_id: 2.to_i64.to_i64, name: "NonAutoCustomPK2"), NonAutoCustomPK.new(custom_id: 3.to_i64, name: "NonAutoCustomPK3"), ] NonAutoCustomPK.import(to_import) to_import = [ NonAutoCustomPK.new(custom_id: 3.to_i64, name: "NonAutoCustomPK3"), ] NonAutoCustomPK.import(to_import, update_on_duplicate: true, columns: ["name"]) record = NonAutoCustomPK.find! 3.to_i64 record.name.should eq "NonAutoCustomPK3" record.custom_id.should eq 3.to_i64 end it "should work with on_duplicate_key_ignore" do to_import = [ NonAutoCustomPK.new(custom_id: 4.to_i64, name: "NonAutoCustomPK4"), NonAutoCustomPK.new(custom_id: 5.to_i64, name: "NonAutoCustomPK5"), NonAutoCustomPK.new(custom_id: 6.to_i64, name: "NonAutoCustomPK6"), ] NonAutoCustomPK.import(to_import) to_import = [ NonAutoCustomPK.new(custom_id: 6.to_i64, name: "NonAutoCustomPK6"), ] NonAutoCustomPK.import(to_import, ignore_on_duplicate: true) record = NonAutoCustomPK.find! 6.to_i64 record.name.should eq "NonAutoCustomPK6" record.custom_id.should eq 6.to_i64 end end end end ================================================ FILE: spec/granite/transactions/save_natural_key_spec.cr ================================================ require "../../spec_helper" describe "(Natural Key) #save" do it "fails when a primary key is not set" do kv = Kvs.new kv.save.should be_false kv.errors.first.message.should eq "Primary key('k') cannot be null" end it "creates a new object when a primary key is given" do kv = Kvs.new kv.k = "foo" kv.save.should be_true kv = Kvs.find!("foo") kv.k.should eq("foo") end it "updates an existing object" do kv = Kvs.new kv.k = "foo2" kv.v = "1" kv.save.should be_true kv.v = "2" kv.save.should be_true kv.k.should eq("foo2") kv.v.should eq("2") end end describe "(Natural Key) usecases" do it "CRUD" do Kvs.clear # # Create port = Kvs.new(k: "mysql_port", v: "3306") port.new_record?.should be_true port.save.should be_true port.v.should eq("3306") Kvs.count.should eq(1) # # Read port = Kvs.find!("mysql_port") port.v.should eq("3306") port.new_record?.should be_false # # Update port.v = "3307" port.new_record?.should be_false port.save.should be_true port.v.should eq("3307") Kvs.count.should eq(1) # # Delete port.destroy.should be_true Kvs.count.should eq(0) end end ================================================ FILE: spec/granite/transactions/save_spec.cr ================================================ require "../../spec_helper" describe "#save" do it "creates a new object" do parent = Parent.new parent.name = "Test Parent" parent.save parent.persisted?.should be_true end it "does not create an invalid object" do parent = Parent.new parent.name = "" parent.save parent.persisted?.should be_false end it "create an invalid object with validation disabled" do parent = Parent.new parent.name = "" parent.save(validate: false) parent.persisted?.should be_true end it "does not create an invalid object with validation explicitly enabled" do parent = Parent.new parent.name = "" parent.save(validate: true) parent.persisted?.should be_false end it "does not save a model with type conversion errors" do model = Comment.new(articleid: "foo") model.errors.size.should eq 1 model.save.should be_false end it "does not update timestamps is skip_timestamps is true" do time = Time.utc(2023, 9, 1) parent = Parent.new parent.name = "Test Parent" parent.created_at = time parent.updated_at = time parent.save(skip_timestamps: true) parent.created_at.should eq time parent.updated_at.should eq time end it "updates an existing object" do Parent.clear parent = Parent.new parent.name = "Test Parent" parent.save parent.name = "Test Parent 2" parent.save parents = Parent.all parents.size.should eq 1 found = Parent.first! found.name.should eq parent.name end it "does not update an invalid object" do parent = Parent.new parent.name = "Test Parent" parent.save parent.name = "" parent.save parent = Parent.find! parent.id parent.name.should eq "Test Parent" end it "update an invalid object with validation disabled" do Parent.clear parent = Parent.new parent.name = "Test Parent" parent.save parent.name = "" parent.save(validate: false) parents = Parent.all parents.size.should eq 1 found = Parent.first! found.name.should eq parent.name end it "does not update an invalid object with validation explicitly enabled" do parent = Parent.new parent.name = "Test Parent" parent.save parent.name = "" parent.save(validate: true) parent = Parent.find! parent.id parent.name.should eq "Test Parent" end it "does not update when the conflicted primary key is given to the new record" do parent1 = Parent.new parent1.name = "Test Parent" parent1.save.should be_true parent2 = Parent.new parent2.id = parent1.id parent2.name = "Test Parent2" parent2.save.should be_false end describe "with a custom primary key" do it "creates a new object" do school = School.new school.name = "Test School" school.save school.custom_id.should_not be_nil end it "updates an existing object" do old_name = "Test School 1" new_name = "Test School 2" school = School.new school.name = old_name school.save primary_key = school.custom_id school.name = new_name school.save found_school = School.find! primary_key found_school.custom_id.should eq primary_key found_school.name.should eq new_name end it "updates states of new_record and persisted" do parent = Parent.new parent.new_record?.should be_true parent.persisted?.should be_false parent.name = "Test Parent" parent.save parent.new_record?.should be_false parent.persisted?.should be_true end end describe "with a modulized model" do it "creates a new object" do county = Nation::County.new county.name = "Test School" county.save county.persisted?.should be_true end it "updates an existing object" do old_name = "Test County 1" new_name = "Test County 2" county = Nation::County.new county.name = old_name county.save primary_key = county.id county.name = new_name county.save found_county = Nation::County.find! primary_key found_county.name.should eq new_name end end describe "using a reserved word as a column name" do # `all` is a reserved word in almost RDB like MySQL, PostgreSQL it "creates and updates" do reserved_word = ReservedWord.new reserved_word.all = "foo" reserved_word.save reserved_word.errors.empty?.should be_true reserved_word.all = "bar" reserved_word.save reserved_word.errors.empty?.should be_true reserved_word.all.should eq("bar") end end end describe "#save!" do it "creates a new object" do parent = Parent.new parent.name = "Test Parent" parent.save! parent.persisted?.should be_true end it "does not create but raise an exception" do parent = Parent.new expect_raises(Granite::RecordNotSaved, "Parent") do parent.save! end end end ================================================ FILE: spec/granite/transactions/touch_spec.cr ================================================ require "../../spec_helper" describe "#touch" do it "should raise on new record" do expect_raises Exception, "Cannot touch on a new record object" { TimeTest.new.touch } end it "should raise on non existent field" do expect_raises Exception, "Field 'foo' does not exist on type 'TimeTest'." do model = TimeTest.create(name: "foo") model.touch(:foo) end end it "should raise on non `Time` field" do expect_raises Exception, "TimeTest.name cannot be touched. It is not of type `Time`." do model = TimeTest.create(name: "foo") model.touch(:name) end end it "updates updated_at on an object" do old_time = Time.utc.at_beginning_of_second object = TimeTest.create(test: old_time) sleep 3 new_time = Time.utc.at_beginning_of_second object.touch object.updated_at.should eq new_time object.test.should eq old_time object.created_at.should eq old_time end it "updates updated_at + custom fields on an object" do old_time = Time.utc.at_beginning_of_second object = TimeTest.create(test: old_time) sleep 3 new_time = Time.utc.at_beginning_of_second object.touch("test") object.updated_at.should eq new_time object.test.should eq new_time object.created_at.should eq old_time end end ================================================ FILE: spec/granite/transactions/update_spec.cr ================================================ require "../../spec_helper" describe "#update" do it "updates an object" do parent = Parent.new(name: "New Parent") parent.save! parent.update(name: "Other parent").should be_true parent.name.should eq "Other parent" Parent.find!(parent.id).name.should eq "Other parent" end it "allows setting a value to nil" do model = Teacher.create!(name: "New Parent") model.update(name: nil) model.name.should be_nil Teacher.find!(model.id).name.should be_nil end it "does not update an invalid object" do parent = Parent.new(name: "New Parent") parent.save! parent.update(name: "").should be_false parent.name.should eq "" Parent.find!(parent.id).name.should eq "New Parent" end context "when created_at is nil" do it "does not update created_at" do parent = Parent.new(name: "New Parent") parent.save! created_at = parent.created_at!.at_beginning_of_second # Simulating instantiating a new object with same ID new_parent = Parent.new(name: "New New Parent") new_parent.id = parent.id new_parent.new_record = false new_parent.updated_at = parent.updated_at new_parent.save! saved_parent = Parent.find!(parent.id) saved_parent.name.should eq "New New Parent" saved_parent.created_at.should eq created_at saved_parent.updated_at.should eq Time.utc.at_beginning_of_second end end context "when skip_timestamps is true" do it "does not update the updated_at field" do time = Time.utc(2023, 9, 1) parent = Parent.create(name: "New Parent") parent.updated_at = time parent.update({name: "Other Parent"}, skip_timestamps: true) Parent.find!(parent.id).updated_at.should eq time end end end describe "#update!" do it "updates an object" do parent = Parent.new(name: "New Parent") parent.save! parent.update!(name: "Other parent") parent.name.should eq "Other parent" Parent.find!(parent.id).name.should eq "Other parent" end it "does not update but raises an exception" do parent = Parent.new(name: "New Parent") parent.save! expect_raises(Granite::RecordNotSaved, "Parent") do parent.update!(name: "") end Parent.find!(parent.id).name.should eq "New Parent" end context "when skip_timestamps is true" do it "does not update the updated_at field" do time = Time.utc(2023, 9, 1) parent = Parent.create(name: "New Parent") parent.updated_at = time parent.update!({name: "Other Parent"}, skip_timestamps: true) Parent.find!(parent.id).updated_at.should eq time end end end ================================================ FILE: spec/granite/validation_helpers/blank_spec.cr ================================================ require "../../spec_helper" describe Granite::ValidationHelpers do context "Blank" do it "should work for is_blank and not_blank" do blank_test = Validators::BlankTest.new blank_test.first_name_not_blank = "" blank_test.last_name_not_blank = " " blank_test.first_name_is_blank = "foo" blank_test.last_name_is_blank = " bar " blank_test.save blank_test.errors.size.should eq 4 blank_test.errors[0].message.should eq "first_name_not_blank must not be blank" blank_test.errors[1].message.should eq "last_name_not_blank must not be blank" blank_test.errors[2].message.should eq "first_name_is_blank must be blank" blank_test.errors[3].message.should eq "last_name_is_blank must be blank" end end end ================================================ FILE: spec/granite/validation_helpers/choice_spec.cr ================================================ require "../../spec_helper" describe Granite::ValidationHelpers do context "Choice" do it "should work for is_valid_choice" do choice_test = Validators::ChoiceTest.new choice_test.number_symbol = 4 choice_test.type_array_symbol = "foo" choice_test.number_string = 2 choice_test.type_array_string = "bar" choice_test.save choice_test.errors.size.should eq 4 choice_test.errors[0].message.should eq "number_symbol has an invalid choice. Valid choices are: 1,2,3" choice_test.errors[1].message.should eq "type_array_symbol has an invalid choice. Valid choices are: internal,external,third_party" choice_test.errors[2].message.should eq "number_string has an invalid choice. Valid choices are: 4,5,6" choice_test.errors[3].message.should eq "type_array_string has an invalid choice. Valid choices are: internal,external,third_party" end end end ================================================ FILE: spec/granite/validation_helpers/exclusion_spec.cr ================================================ require "../../spec_helper" describe Granite::ValidationHelpers do context "Exclusion" do it "should allow non reserved words" do exclusion = Validators::ExclusionTest.new exclusion.name = "none_conflicting" exclusion.save exclusion.errors.size.should eq 0 end it "should disallow reservered words" do exclusion = Validators::ExclusionTest.new exclusion.name = "test_name" exclusion.save exclusion.errors.size.should eq 1 exclusion.errors[0].message.should eq "Name got reserved values. Reserved values are test_name" end end end ================================================ FILE: spec/granite/validation_helpers/inequality_spec.cr ================================================ require "../../spec_helper" describe Granite::ValidationHelpers do context "Less Than" do it "should work for less_than" do less_than_test = Validators::LessThanTest.new less_than_test.int_32_lt = 10 less_than_test.float_32_lt = 20.5.to_f32 less_than_test.int_32_lte = 52 less_than_test.float_32_lte = 155.55.to_f32 less_than_test.save less_than_test.errors.size.should eq 4 less_than_test.errors[0].message.should eq "int_32_lt must be less than 10" less_than_test.errors[1].message.should eq "float_32_lt must be less than 20.5" less_than_test.errors[2].message.should eq "int_32_lte must be less than or equal to 50" less_than_test.errors[3].message.should eq "float_32_lte must be less than or equal to 100.25" less_than_test_nil = Validators::LessThanTest.new expect_raises(Exception, "Nil assertion failed") do less_than_test_nil.save end end end context "Greater Than" do it "should work for greater_than" do greater_than_test = Validators::GreaterThanTest.new greater_than_test.int_32_lt = 10 greater_than_test.float_32_lt = 20.5.to_f32 greater_than_test.int_32_lte = 49 greater_than_test.float_32_lte = 100.20.to_f32 greater_than_test.save greater_than_test.errors.size.should eq 4 greater_than_test.errors[0].message.should eq "int_32_lt must be greater than 10" greater_than_test.errors[1].message.should eq "float_32_lt must be greater than 20.5" greater_than_test.errors[2].message.should eq "int_32_lte must be greater than or equal to 50" greater_than_test.errors[3].message.should eq "float_32_lte must be greater than or equal to 100.25" greater_than_test = Validators::GreaterThanTest.new expect_raises(Exception, "Nil assertion failed") do greater_than_test.save end end end end ================================================ FILE: spec/granite/validation_helpers/lenght_spec.cr ================================================ require "../../spec_helper" describe Granite::ValidationHelpers do context "Length" do it "should work for length" do length_test = Validators::LengthTest.new length_test.title = "one" length_test.description = "abcdefghijklmnopqrstuvwxyz" length_test.save length_test.errors.size.should eq 2 length_test.errors[0].message.should eq "title is too short. It must be at least 5" length_test.errors[1].message.should eq "description is too long. It must be at most 25" end end end ================================================ FILE: spec/granite/validation_helpers/nil_spec.cr ================================================ require "../../spec_helper" describe Granite::ValidationHelpers do context "Nil" do it "should work for is_nil and not_nil for all data types" do nil_test = Validators::NilTest.new nil_test.first_name = "John" nil_test.last_name = "Smith" nil_test.age = 32 nil_test.born = true nil_test.value = 123.56.to_f32 nil_test.save nil_test.errors.size.should eq 10 nil_test.errors[0].message.should eq "first_name_not_nil must not be nil" nil_test.errors[1].message.should eq "last_name_not_nil must not be nil" nil_test.errors[2].message.should eq "age_not_nil must not be nil" nil_test.errors[3].message.should eq "born_not_nil must not be nil" nil_test.errors[4].message.should eq "value_not_nil must not be nil" nil_test.errors[5].message.should eq "first_name must be nil" nil_test.errors[6].message.should eq "last_name must be nil" nil_test.errors[7].message.should eq "age must be nil" nil_test.errors[8].message.should eq "born must be nil" nil_test.errors[9].message.should eq "value must be nil" end end end ================================================ FILE: spec/granite/validation_helpers/uniqueness_spec.cr ================================================ require "../../spec_helper" describe Granite::ValidationHelpers do context "Uniqueness" do before_each do Validators::PersonUniqueness.migrator.drop_and_create end it "should work for uniqueness" do person_uniqueness1 = Validators::PersonUniqueness.new person_uniqueness2 = Validators::PersonUniqueness.new person_uniqueness1.name = "awesomeName" person_uniqueness2.name = "awesomeName" person_uniqueness1.save person_uniqueness2.save person_uniqueness1.errors.size.should eq 0 person_uniqueness2.errors.size.should eq 1 person_uniqueness2.errors[0].message.should eq "name should be unique" end it "should work for uniqueness on the same instance" do person_uniqueness1 = Validators::PersonUniqueness.new person_uniqueness1.name = "awesomeName" person_uniqueness1.save person_uniqueness1.errors.size.should eq 0 person_uniqueness1.name = "awesomeName" person_uniqueness1.save person_uniqueness1.errors.size.should eq 0 end end end ================================================ FILE: spec/granite/validations/validator_spec.cr ================================================ require "../../spec_helper" {% begin %} {% adapter_literal = env("CURRENT_ADAPTER").id %} class NameTest < Granite::Base connection {{ adapter_literal }} column id : Int64, primary: true column name : String? validate :name, "cannot be blank", ->(s : NameTest) do !s.name.to_s.blank? end end class EmailTest < Granite::Base connection {{ adapter_literal }} column id : Int64, primary: true column email : String? validate :email, "cannot be blank" do |email_test| !email_test.email.to_s.blank? end end class PasswordTest < Granite::Base connection {{ adapter_literal }} column id : Int64, primary: true column password : String? column password_validation : String? validate "password and validation should match" do |password_test| password_test.password == password_test.password_validation end end describe Granite::Validators do describe "validates using proc" do it "returns true if name is set" do subject = NameTest.new subject.name = "name" subject.valid?.should eq true end it "returns false if name is blank" do subject = NameTest.new subject.name = "" subject.valid?.should eq false end end describe "validates using block" do it "returns true if email is set" do subject = EmailTest.new subject.email = "test@example.com" subject.valid?.should eq true end it "returns false if email is blank" do subject = EmailTest.new subject.email = "" subject.valid?.should eq false end end describe "validates using block without field" do it "returns true if passwords match" do subject = PasswordTest.new subject.password = "123" subject.password_validation = "123" subject.valid?.should eq true end it "returns false if password does not match" do subject = PasswordTest.new subject.password = "123" subject.password_validation = "1234" subject.valid?.should eq false end end describe "validates cleanly after previously failing" do it "returns true if name is rectified after after failing" do subject = NameTest.new subject.name = "" subject.valid?.should eq false subject.name = "name" subject.valid?.should eq true end end end {% end %} ================================================ FILE: spec/granite_spec.cr ================================================ require "./spec_helper" class SomeClass def initialize(@model_class : Granite::Base.class); end def valid? : Bool @model_class.exists? 123 end def table : String @model_class.table_name end end describe Granite::Base do it "class methods should work when type restricted to `Granite::Base`" do f = SomeClass.new(Teacher) f.valid?.should be_false f.table.should eq "teachers" end it "should allow false as a column value" do model = BoolModel.create active: false model.active.should be_false model.id.should eq 1 fetched_model = BoolModel.find! model.id fetched_model.active.should be_false end describe "instantiation" do describe "with default values" do it "should instaniate correctly" do model = DefaultValues.new model.name.should eq "Jim" model.age.should eq 0.0 model.is_alive.should be_true end end describe "with a named tuple" do it "should instaniate correctly" do model = DefaultValues.new name: "Fred", is_alive: false model.name.should eq "Fred" model.age.should eq 0.0 model.is_alive.should be_false end end describe "with a hash" do it "should instaniate correctly" do model = DefaultValues.new({"name" => "Bob", "age" => 3.14}) model.name.should eq "Bob" model.age.should eq 3.14 model.is_alive.should be_true end end describe "with a UUID" do it "should instaniate correctly" do uuid = UUID.random model = UUIDNaturalModel.new uuid: uuid, field_uuid: uuid model.uuid.should be_a UUID? model.field_uuid.should be_a UUID? model.uuid.should eq uuid model.field_uuid.should eq uuid end end describe "with string numeric values" do it "should instaniate correctly" do model = StringConversion.new({"user_id" => "1", "int32" => "17", "float32" => "3.14", "float" => "92342.2342342"}) model.user_id.should be_a Int64 model.user_id.should eq 1 model.int32.should be_a Int32 model.int32.should eq 17 model.float32.should be_a Float32 model.float32.should eq 3.14_f32 model.float.should be_a Float64 model.float.should eq 92342.2342342 end end end describe Log do it "should be logged as DEBUG" do backend = Log::MemoryBackend.new Log.builder.bind "granite", :debug, backend Person.first backend.entries.first.severity.debug?.should be_true backend.entries.first.message.should match /.*SELECT.*people.*id.*FROM.*people.*LIMIT.*1.*: .*\[\]/ end it "should not be logged" do a = 0 Log.for("granite.test").info { a = 1 } a.should eq 0 end end describe "JSON" do describe ".from_json" do it "can create an object from json" do json_str = %({"name": "json::anyReview","upvotes": 2, "sentiment": 1.23, "interest": 4.56, "published": true}) review = Review.from_json(json_str) review.name.should eq "json::anyReview" review.upvotes.should eq 2 review.sentiment.should eq 1.23_f32 review.interest.should eq 4.56 review.published.should eq true review.created_at.should be_nil end it "can create an array of objects from json" do json_str = %([{"name": "json1","upvotes": 2, "sentiment": 1.23, "interest": 4.56, "published": true},{"name": "json2","upvotes": 0, "sentiment": 5.00, "interest": 6.99, "published": false}]) review = Array(Review).from_json(json_str) review[0].name.should eq "json1" review[0].upvotes.should eq 2 review[0].sentiment.should eq 1.23_f32 review[0].interest.should eq 4.56 review[0].published.should be_true review[0].created_at.should be_nil review[1].name.should eq "json2" review[1].upvotes.should eq 0 review[1].sentiment.should eq 5.00_f32 review[1].interest.should eq 6.99 review[1].published.should be_false review[1].created_at.should be_nil end it "works with after_initialize" do model = AfterInit.from_json(%({"name": "after_initialize"})) model.name.should eq "after_initialize" model.priority.should eq 1000 end describe "with default values" do it "correctly applies values" do model = DefaultValues.from_json(%({"name": "Bob"})) model.name.should eq "Bob" model.age.should eq 0.0 model.is_alive.should be_true end end end describe "#to_json" do it "emits nil values when told" do t = TodoEmitNull.new(name: "test todo", priority: 20) result = %({"id":null,"name":"test todo","priority":20,"created_at":null,"updated_at":null}) t.to_json.should eq result end it "does not emit nil values by default" do t = Todo.new(name: "test todo", priority: 20) result = %({"name":"test todo","priority":20}) t.to_json.should eq result end it "works with array of models" do todos = [ Todo.new(name: "todo 1", priority: 1), Todo.new(name: "todo 2", priority: 2), Todo.new(name: "todo 3", priority: 3), ] collection = todos.to_json collection.should eq %([{"name":"todo 1","priority":1},{"name":"todo 2","priority":2},{"name":"todo 3","priority":3}]) end end context "with json_options" do model = TodoJsonOptions.from_json(%({"task_name": "The Task", "priority": 9000})) it "should deserialize correctly" do model.name.should eq "The Task" model.priority.should be_nil end it "should serialize correctly" do model.to_json.should eq %({"task_name":"The Task"}) end describe "when using timestamp fields" do TodoJsonOptions.import([ TodoJsonOptions.new(name: "first todo", priority: 200), TodoJsonOptions.new(name: "second todo", priority: 500), TodoJsonOptions.new(name: "third todo", priority: 300), ]) it "should serialize correctly" do todos = TodoJsonOptions.order(id: :asc).select todos[0].to_json.should eq %({"id":1,"task_name":"first todo","posted":"#{Time::Format::RFC_3339.format(todos[0].created_at!)}"}) todos[1].to_json.should eq %({"id":2,"task_name":"second todo","posted":"#{Time::Format::RFC_3339.format(todos[1].created_at!)}"}) todos[2].to_json.should eq %({"id":3,"task_name":"third todo","posted":"#{Time::Format::RFC_3339.format(todos[2].created_at!)}"}) end end end end describe "YAML" do describe ".from_yaml" do it "can create an object from YAML" do yaml_str = %(---\nname: yaml::anyReview\nupvotes: 2\nsentiment: 1.23\ninterest: 4.56\npublished: true) review = Review.from_yaml(yaml_str) review.name.should eq "yaml::anyReview" review.upvotes.should eq 2 review.sentiment.should eq 1.23.to_f32 review.interest.should eq 4.56 review.published.should eq true review.created_at.should be_nil end it "can create an array of objects from YAML" do yaml_str = "---\n- name: yaml1\n upvotes: 2\n sentiment: 1.23\n interest: 4.56\n published: true\n- name: yaml2\n upvotes: 0\n sentiment: !!float 5\n interest: 6.99\n published: false" review = Array(Review).from_yaml(yaml_str) review[0].name.should eq "yaml1" review[0].upvotes.should eq 2 review[0].sentiment.should eq 1.23.to_f32 review[0].interest.should eq 4.56 review[0].published.should be_true review[0].created_at.should be_nil review[1].name.should eq "yaml2" review[1].upvotes.should eq 0 review[1].sentiment.should eq 5.00.to_f32 review[1].interest.should eq 6.99 review[1].published.should be_false review[1].created_at.should be_nil end it "works with after_initialize" do model = AfterInit.from_yaml(%(---\nname: after_initialize)) model.name.should eq "after_initialize" model.priority.should eq 1000 end describe "with default values" do it "correctly applies values" do model = DefaultValues.from_yaml(%(---\nname: Bob)) model.name.should eq "Bob" model.age.should eq 0.0 model.is_alive.should be_true end end end describe "#to_yaml" do it "emits nil values when told" do t = TodoEmitNull.new(name: "test todo", priority: 20) result = %(---\nid:\nname: test todo\npriority: 20\ncreated_at:\nupdated_at:\n) t.to_yaml.should eq result end it "does not emit nil values by default" do t = Todo.new(name: "test todo", priority: 20) result = %(---\nname: test todo\npriority: 20\n) t.to_yaml.should eq result end it "works with array of models" do todos = [ Todo.new(name: "todo 1", priority: 1), Todo.new(name: "todo 2", priority: 2), Todo.new(name: "todo 3", priority: 3), ] collection = todos.to_yaml collection.should eq %(---\n- name: todo 1\n priority: 1\n- name: todo 2\n priority: 2\n- name: todo 3\n priority: 3\n) end end context "with yaml_options" do model = TodoYamlOptions.from_yaml(%(---\ntask_name: The Task\npriority: 9000)) it "should deserialize correctly" do model.name.should eq "The Task" model.priority.should be_nil end it "should serialize correctly" do model.to_yaml.should eq %(---\ntask_name: The Task\n) end describe "when using timestamp fields" do TodoYamlOptions.import([ TodoYamlOptions.new(name: "first todo", priority: 200), TodoYamlOptions.new(name: "second todo", priority: 500), TodoYamlOptions.new(name: "third todo", priority: 300), ]) it "should serialize correctly" do todos = TodoYamlOptions.order(id: :asc).select todos[0].to_yaml.should eq %(---\nid: 1\ntask_name: first todo\nposted: #{Time::Format::YAML_DATE.format(todos[0].created_at!)}\n) todos[1].to_yaml.should eq %(---\nid: 2\ntask_name: second todo\nposted: #{Time::Format::YAML_DATE.format(todos[1].created_at!)}\n) todos[2].to_yaml.should eq %(---\nid: 3\ntask_name: third todo\nposted: #{Time::Format::YAML_DATE.format(todos[2].created_at!)}\n) end end end end describe "#to_h" do it "convert object to hash" do t = Todo.new(name: "test todo", priority: 20) result = {"id" => nil, "name" => "test todo", "priority" => 20, "created_at" => nil, "updated_at" => nil} t.to_h.should eq result end it "honors custom primary key" do s = Item.new(item_name: "Hacker News") s.item_id = "three" s.to_h.should eq({"item_name" => "Hacker News", "item_id" => "three"}) end it "works with enums" do model = EnumModel.new model.my_enum = MyEnum::One model.to_h.should eq({"id" => nil, "my_enum" => MyEnum::One}) end end # Only PG supports array types {% if env("CURRENT_ADAPTER") == "pg" %} describe "Array(T)" do describe "with values" do it "should instantiate correctly" do model = ArrayModel.new str_array: ["foo", "bar"] model.str_array.should eq ["foo", "bar"] end it "should save correctly" do model = ArrayModel.new model.id = 1 model.str_array = ["jack", "john", "jill"] model.i16_array = [10_000_i16, 20_000_i16, 30_000_i16] model.i32_array = [1_000_000_i32, 2_000_000_i32, 3_000_000_i32, 4_000_000_i32] model.i64_array = [100_000_000_000_i64, 200_000_000_000_i64, 300_000_000_000_i64, 400_000_000_000_i64] model.f32_array = [1.123_456_78_f32, 1.234_567_899_998_741_4_f32] model.f64_array = [1.123_456_789_011_23_f64, 1.234_567_899_998_741_4_f64] model.bool_array = [true, true, false, true, false, false] model.save.should be_true end it "should read correctly" do model = ArrayModel.find! 1 model.str_array!.should be_a Array(String) model.str_array!.should eq ["jack", "john", "jill"] model.i16_array!.should be_a Array(Int16) model.i16_array!.should eq [10_000_i16, 20_000_i16, 30_000_i16] model.i32_array!.should be_a Array(Int32) model.i32_array!.should eq [1_000_000_i32, 2_000_000_i32, 3_000_000_i32, 4_000_000_i32] model.i64_array!.should be_a Array(Int64) model.i64_array!.should eq [100_000_000_000_i64, 200_000_000_000_i64, 300_000_000_000_i64, 400_000_000_000_i64] model.f32_array!.should be_a Array(Float32) model.f32_array!.should eq [1.123_456_78_f32, 1.234_567_899_998_741_4_f32] model.f64_array!.should be_a Array(Float64) model.f64_array!.should eq [1.123_456_789_011_23_f64, 1.234_567_899_998_741_4_f64] model.bool_array!.should be_a Array(Bool) model.bool_array!.should eq [true, true, false, true, false, false] end end describe "with empty array" do it "should save correctly" do model = ArrayModel.new model.id = 2 model.str_array = [] of String model.f64_array.should be_a(Array(Float64)) model.f64_array.should eq [] of Float64 model.save.should be_true end it "should read correctly" do model = ArrayModel.find! 2 model.str_array.should be_a Array(String)? model.str_array!.should eq [] of String model.i16_array.should be_nil model.i32_array.should be_nil model.i64_array.should be_nil model.f32_array.should be_nil model.f64_array.should be_a(Array(Float64)) model.f64_array.should eq [] of Float64 model.bool_array.should be_nil end end end {% end %} end ================================================ FILE: spec/mocks/db_mock.cr ================================================ class FakeStatement < DB::Statement protected def perform_query(args : Enumerable) : DB::ResultSet FieldEmitter.new end protected def perform_exec(args : Enumerable) : DB::ExecResult DB::ExecResult.new 0_i64, 0_i64 end end class FakeContext include DB::ConnectionContext def uri : URI URI.new "" end def prepared_statements? : Bool false end def discard(connection); end def release(connection); end end class FakeConnection < DB::Connection def initialize super(DB::Connection::Options.new) @context = FakeContext.new @prepared_statements = false end def build_unprepared_statement(query) : FakeStatement FakeStatement.new self, query end def build_prepared_statement(query) : FakeStatement FakeStatement.new self, query end end alias EmitterType = DB::Any | PG::Numeric | JSON::Any | Int16 # FieldEmitter emulates the subtle and uninformed way that # DB::ResultSet emits data. To be used in testing interactions # with raw data sets. class FieldEmitter < DB::ResultSet # 1. Override `#move_next` to move to the next row. # 2. Override `#read` returning the next value in the row. # 3. (Optional) Override `#read(t)` for some types `t` for which custom logic other than a simple cast is needed. # 4. Override `#column_count`, `#column_name`. @position = 0 @field_position = 0 @values = [] of EmitterType def initialize @statement = FakeStatement.new FakeConnection.new, "" end def _set_values(values : Array(EmitterType)) @values = [] of EmitterType values.each do |v| @values << v end end def move_next : Bool @position += 1 @field_position = 0 @position < @values.size end def read if @position >= @values.size raise "Overread" end @values[@position].tap do @position += 1 end end def column_count : Int32 @values.size end def column_name(index : Int32) : String "Column #{index}" end def next_column_index : Int32 @field_position end end ================================================ FILE: spec/run_all_specs.sh ================================================ #! /bin/bash source .env echo "Testing PG" docker-compose -f docker/docker-compose.pg.yml build spec docker-compose -f docker/docker-compose.pg.yml run spec echo "Testing mysql" docker-compose -f docker/docker-compose.mysql.yml build spec docker-compose -f docker/docker-compose.mysql.yml run spec echo "Testing sqlite" docker-compose -f docker/docker-compose.sqlite.yml build spec docker-compose -f docker/docker-compose.sqlite.yml run spec echo "Done testing...stopping/removing images" docker-compose -f docker/docker-compose.sqlite.yml down docker-compose -f docker/docker-compose.mysql.yml down docker-compose -f docker/docker-compose.pg.yml down ================================================ FILE: spec/run_test_dbs.sh ================================================ #!/bin/bash MYSQL_VERSION=${MYSQL_VERSION:-5.7} PG_VERSION=${PG_VERSION:-15.2} docker run --name mysql -d \ -e MYSQL_ROOT_PASSWORD=password \ -e MYSQL_DATABASE=granite_db \ -e MYSQL_USER=granite \ -e MYSQL_PASSWORD=password \ -p 3306:3306 \ mysql:${MYSQL_VERSION} docker run --name psql -d \ -e POSTGRES_USER=granite \ -e POSTGRES_PASSWORD=password \ -e POSTGRES_DB=granite_db \ -p 5432:5432 \ postgres:${PG_VERSION} ================================================ FILE: spec/spec_helper.cr ================================================ require "mysql" require "pg" require "sqlite3" CURRENT_ADAPTER = ENV["CURRENT_ADAPTER"] ADAPTER_URL = ENV["#{CURRENT_ADAPTER.upcase}_DATABASE_URL"] ADAPTER_REPLICA_URL = ENV["#{CURRENT_ADAPTER.upcase}_REPLICA_URL"]? || ADAPTER_URL case CURRENT_ADAPTER when "pg" Granite::Connections << Granite::Adapter::Pg.new(name: CURRENT_ADAPTER, url: ADAPTER_URL) Granite::Connections << {name: "pg_with_replica", writer: ADAPTER_URL, reader: ADAPTER_REPLICA_URL, adapter_type: Granite::Adapter::Pg} when "mysql" Granite::Connections << Granite::Adapter::Mysql.new(name: CURRENT_ADAPTER, url: ADAPTER_URL) Granite::Connections << {name: "mysql_with_replica", writer: ADAPTER_URL, reader: ADAPTER_REPLICA_URL, adapter_type: Granite::Adapter::Mysql} when "sqlite" Granite::Connections << Granite::Adapter::Sqlite.new(name: CURRENT_ADAPTER, url: ADAPTER_URL) Granite::Connections << {name: "sqlite_with_replica", writer: ADAPTER_URL, reader: ADAPTER_REPLICA_URL, adapter_type: Granite::Adapter::Sqlite} when Nil raise "Please set CURRENT_ADAPTER" else raise "Unknown adapter #{CURRENT_ADAPTER}" end require "spec" require "../src/granite" require "../src/adapter/**" require "./spec_models" require "./mocks/**" Spec.before_suite do Granite.settings.default_timezone = Granite::TIME_ZONE {% if flag?(:spec_logs) %} ::Log.builder.bind( # source: "spec.client", source: "*", level: ::Log::Severity::Trace, backend: ::Log::IOBackend.new(STDOUT, dispatcher: :sync), ) {% end %} end Spec.before_each do # I have no idea why this is needed, but it is. Granite.settings.default_timezone = Granite::TIME_ZONE end {% if env("CURRENT_ADAPTER") == "mysql" && !flag?(:issue_473) %} Spec.after_each do # https://github.com/amberframework/granite/issues/473 Granite::Connections["mysql"].not_nil![:writer].try &.database.pool.close end {% end %} ================================================ FILE: spec/spec_models.cr ================================================ require "uuid" class Granite::Base def self.drop_and_create end end {% begin %} {% adapter_literal = env("CURRENT_ADAPTER").id %} class ReplicatedChat < Granite::Base connection {{ "#{adapter_literal}_with_replica" }} table replicated_chats column id : Int64, primary: true column content : String end class Chat < Granite::Base connection {{ adapter_literal }} table chats column id : Int64, primary: true column name : String has_one settings : ChatSettings, foreign_key: :chat_id end class ChatSettings < Granite::Base connection {{ adapter_literal }} table chat_settings belongs_to chat : Chat, primary: true column flood_limit : Int32 end class Parent < Granite::Base connection {{ adapter_literal }} table parents column id : Int64, primary: true column name : String? timestamps has_many :students, class_name: Student validate :name, "Name cannot be blank" do |parent| !parent.name.to_s.blank? end end class Teacher < Granite::Base connection {{ adapter_literal }} table teachers column id : Int64, primary: true column name : String? has_many :klasses, class_name: Klass end class Student < Granite::Base connection {{ adapter_literal }} table students column id : Int64, primary: true column name : String? has_many :enrollments, class_name: Enrollment has_many :klasses, class_name: Klass, through: :enrollments end class Klass < Granite::Base connection {{ adapter_literal }} table klasses column id : Int64, primary: true column name : String? belongs_to teacher : Teacher has_many :enrollments, class_name: Enrollment has_many :students, class_name: Student, through: :enrollments end class Enrollment < Granite::Base connection {{ adapter_literal }} table enrollments column id : Int64, primary: true belongs_to :student belongs_to :klass end class School < Granite::Base connection {{ adapter_literal }} table schools column custom_id : Int64, primary: true column name : String? end class User < Granite::Base connection {{ adapter_literal }} table users column id : Int64, primary: true column email : String? has_one :profile end class Character < Granite::Base connection {{ adapter_literal }} table characters column character_id : Int32, primary: true column name : String end class Courier < Granite::Base connection {{ adapter_literal }} table couriers column courier_id : Int32, primary: true, auto: false column issuer_id : Int32 belongs_to service : CourierService, primary_key: "owner_id" has_one issuer : Character, primary_key: "issuer_id", foreign_key: "character_id" end class CourierService < Granite::Base connection {{ adapter_literal }} table services column owner_id : Int64, primary: true, auto: false column name : String has_many :couriers, class_name: Courier, foreign_key: "service_id" end class Profile < Granite::Base connection {{ adapter_literal }} table profiles column id : Int64, primary: true column name : String? belongs_to :user end class Nation::County < Granite::Base connection {{ adapter_literal }} table nation_counties column id : Int64, primary: true column name : String? end class Review < Granite::Base connection {{ adapter_literal }} table reviews column id : Int64, primary: true column name : String? column downvotes : Int32? column upvotes : Int64? column sentiment : Float32? column interest : Float64? column published : Bool? column created_at : Time? end class Empty < Granite::Base connection {{ adapter_literal }} table empties column id : Int64, primary: true end class ReservedWord < Granite::Base connection {{ adapter_literal }} table "select" column id : Int64, primary: true column all : String? end class Callback < Granite::Base connection {{ adapter_literal }} table callbacks column id : Int64, primary: true column name : String? property history : IO::Memory = IO::Memory.new {% for name in Granite::Callbacks::CALLBACK_NAMES %} {{name.id}} _{{name.id}} private def _{{name.id}} history << "{{name.id}}\n" end {% end %} end class CallbackWithAbort < Granite::Base connection {{ adapter_literal }} table callbacks_with_abort column abort_at : String, primary: true, auto: false column do_abort : Bool? column name : String? property history : IO::Memory = IO::Memory.new {% for name in Granite::Callbacks::CALLBACK_NAMES %} {{name.id}} do abort! if do_abort && abort_at == "{{name.id}}" history << "{{name.id}}\n" end {% end %} end class Kvs < Granite::Base connection {{ adapter_literal }} table kvs column k : String, primary: true, auto: false column v : String? end class Person < Granite::Base connection {{ adapter_literal }} table people column id : Int64, primary: true column name : String? end class Company < Granite::Base connection {{ adapter_literal }} table companies column id : Int32, primary: true column name : String? end class Book < Granite::Base connection {{ adapter_literal }} table books column id : Int32, primary: true column name : String? @[JSON::Field(ignore: true)] @[YAML::Field(ignore: true)] belongs_to publisher : Company, foreign_key: publisher_id : Int32? has_many :book_reviews, class_name: BookReview belongs_to author : Person end class BookReview < Granite::Base connection {{ adapter_literal }} table book_reviews column id : Int32, primary: true column body : String? belongs_to book : Book, foreign_key: book_id : Int32? end class Item < Granite::Base connection {{ adapter_literal }} table items column item_id : String, primary: true, auto: false column item_name : String? before_create :generate_uuid def generate_uuid @item_id = UUID.random.to_s end end class NonAutoDefaultPK < Granite::Base connection {{ adapter_literal }} table non_auto_default_pk column id : Int64, primary: true, auto: false column name : String? end class NonAutoCustomPK < Granite::Base connection {{ adapter_literal }} table non_auto_custom_pk column custom_id : Int64, primary: true, auto: false column name : String? end class Article < Granite::Base connection {{ adapter_literal }} table articles column id : Int64, primary: true column articlebody : String? end class Comment < Granite::Base connection {{ adapter_literal }} table comments column id : Int64, primary: true column commentbody : String? column articleid : Int64? end class SongThread < Granite::Base connection {{ env("CURRENT_ADAPTER").id }} column id : Int64, primary: true column name : String? end class CustomSongThread < Granite::Base connection {{ env("CURRENT_ADAPTER").id }} table custom_table_name column custom_primary_key : Int64, primary: true column name : String? end @[JSON::Serializable::Options(emit_nulls: true)] @[YAML::Serializable::Options(emit_nulls: true)] class TodoEmitNull < Granite::Base connection {{ adapter_literal }} table todos column id : Int64, primary: true column name : String? column priority : Int32? timestamps end class Todo < Granite::Base connection {{ adapter_literal }} table todos column id : Int64, primary: true column name : String? column priority : Int32? timestamps end class AfterInit < Granite::Base connection {{ adapter_literal }} table after_json_init column id : Int64, primary: true column name : String? column priority : Int32? def after_initialize @priority = 1000 end end class ArticleViewModel < Granite::Base connection {{ adapter_literal }} column id : Int64, primary: true column articlebody : String? column commentbody : String? select_statement <<-SQL SELECT articles.id, articles.articlebody, comments.commentbody FROM articles JOIN comments ON comments.articleid = articles.id SQL end # Only PG supports array types {% if env("CURRENT_ADAPTER") == "pg" %} class ArrayModel < Granite::Base connection {{ adapter_literal }} column id : Int32, primary: true column str_array : Array(String)? column i16_array : Array(Int16)? column i32_array : Array(Int32)? column i64_array : Array(Int64)? column f32_array : Array(Float32)? column f64_array : Array(Float64)? = [] of Float64 column bool_array : Array(Bool)? end ArrayModel.migrator.drop_and_create {% end %} class UUIDModel < Granite::Base connection {{ adapter_literal }} table uuids column uuid : UUID?, primary: true end class UUIDRelation < Granite::Base connection {{ adapter_literal }} table uuid_relations column uuid : UUID?, primary: true, converter: Granite::Converters::Uuid(String) belongs_to uuid_model : UUIDModel, foreign_key: uuid_model_id : UUID, primary_key: :uuid, converter: Granite::Converters::Uuid(String) end class UUIDNaturalModel < Granite::Base connection {{ adapter_literal }} table uuids column uuid : UUID, primary: true, auto: false column field_uuid : UUID? end class TodoJsonOptions < Granite::Base connection {{ adapter_literal }} table todos_json column id : Int64, primary: true @[JSON::Field(key: "task_name")] column name : String? @[JSON::Field(ignore: true)] column priority : Int32? @[JSON::Field(ignore: true)] column updated_at : Time? @[JSON::Field(key: "posted")] column created_at : Time? end class TodoYamlOptions < Granite::Base connection {{ adapter_literal }} table todos_yaml column id : Int64, primary: true @[YAML::Field(key: "task_name")] column name : String? @[YAML::Field(ignore: true)] column priority : Int32? @[YAML::Field(ignore: true)] column updated_at : Time? @[YAML::Field(key: "posted")] column created_at : Time? end class DefaultValues < Granite::Base connection {{ adapter_literal }} table defaults column id : Int64, primary: true column name : String = "Jim" column is_alive : Bool = true column age : Float64 = 0.0 end class TimeTest < Granite::Base connection {{ adapter_literal }} table times column id : Int64, primary: true column test : Time? column name : String? timestamps end class ManualColumnType < Granite::Base connection {{ adapter_literal }} table manual_column_types column id : Int64, primary: true column foo : UUID?, column_type: "DECIMAL(12, 10)" end class EventCon < Granite::Base connection {{ adapter_literal }} table "event_cons" column id : Int64, primary: true column con_name : String column event_name : String? select_statement <<-SQL select con_name FROM event_cons SQL end class StringConversion < Granite::Base connection {{ adapter_literal }} table "string_conversions" belongs_to :user column id : Int64, primary: true column int32 : Int32 column float32 : Float32 column float : Float64 end class BoolModel < Granite::Base connection {{ adapter_literal }} table "bool_model" column id : Int64, primary: true column active : Bool = true end struct MyType include JSON::Serializable def initialize; end property name : String = "Jim" property age : Int32 = 12 end enum MyEnum Zero One Two Three Four end class EnumModel < Granite::Base connection {{ adapter_literal }} table enum_model column id : Int64, primary: true column my_enum : MyEnum?, column_type: "TEXT", converter: Granite::Converters::Enum(MyEnum, String) end class MyApp::Namespace::Model < Granite::Base connection {{ adapter_literal }} column id : Int64, primary: true end {% if env("CURRENT_ADAPTER") == "pg" %} class ConverterModel < Granite::Base connection {{ adapter_literal }} table converters column id : Int64, primary: true column binary_json : MyType?, column_type: "BYTEA", converter: Granite::Converters::Json(MyType, Bytes) column string_json : MyType?, column_type: "JSON", converter: Granite::Converters::Json(MyType, JSON::Any) column string_jsonb : MyType?, column_type: "JSONB", converter: Granite::Converters::Json(MyType, JSON::Any) column smallint_enum : MyEnum?, column_type: "SMALLINT", converter: Granite::Converters::Enum(MyEnum, Int16) column bigint_enum : MyEnum?, column_type: "BIGINT", converter: Granite::Converters::Enum(MyEnum, Int64) column string_enum : MyEnum?, column_type: "TEXT", converter: Granite::Converters::Enum(MyEnum, String) column enum_enum : MyEnum?, column_type: "my_enum_type", converter: Granite::Converters::Enum(MyEnum, Bytes) column binary_enum : MyEnum?, column_type: "BYTEA", converter: Granite::Converters::Enum(MyEnum, Bytes) column numeric : Float64?, column_type: "DECIMAL(21, 20)", converter: Granite::Converters::PgNumeric end ConverterModel.exec(<<-TYPE DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'my_enum_type') THEN CREATE TYPE my_enum_type AS ENUM ('Zero', 'One', 'Two', 'Three', 'Four'); END IF; END$$; TYPE ) {% elsif env("CURRENT_ADAPTER") == "sqlite" %} class ConverterModel < Granite::Base connection {{ adapter_literal }} table converters column id : Int64, primary: true column binary_json : MyType?, column_type: "BLOB", converter: Granite::Converters::Json(MyType, Bytes) column string_json : MyType?, column_type: "TEXT", converter: Granite::Converters::Json(MyType, String) column int_enum : MyEnum?, column_type: "INTEGER", converter: Granite::Converters::Enum(MyEnum, Int64) column string_enum : MyEnum?, column_type: "TEXT", converter: Granite::Converters::Enum(MyEnum, String) column binary_enum : MyEnum?, column_type: "BLOB", converter: Granite::Converters::Enum(MyEnum, String) end {% elsif env("CURRENT_ADAPTER") == "mysql" %} class ConverterModel < Granite::Base connection {{ adapter_literal }} table converters column id : Int64, primary: true column binary_json : MyType?, column_type: "BLOB", converter: Granite::Converters::Json(MyType, Bytes) column string_json : MyType?, column_type: "TEXT", converter: Granite::Converters::Json(MyType, String) column int_enum : MyEnum?, column_type: "INTEGER", converter: Granite::Converters::Enum(MyEnum, Int32) column string_enum : MyEnum?, column_type: "VARCHAR(5)", converter: Granite::Converters::Enum(MyEnum, String) column enum_enum : MyEnum?, column_type: "ENUM('Zero', 'One', 'Two', 'Three', 'Four')", converter: Granite::Converters::Enum(MyEnum, String) column binary_enum : MyEnum?, column_type: "BLOB", converter: Granite::Converters::Enum(MyEnum, Bytes) end {% end %} module Validators class NilTest < Granite::Base connection {{ adapter_literal }} column id : Int64, primary: true column first_name_not_nil : String? column last_name_not_nil : String? column age_not_nil : Int32? column born_not_nil : Bool? column value_not_nil : Float32? column first_name : String? column last_name : String? column age : Int32? column born : Bool? column value : Float32? validate_not_nil "first_name_not_nil" validate_not_nil :last_name_not_nil validate_not_nil :age_not_nil validate_not_nil "born_not_nil" validate_not_nil :value_not_nil validate_is_nil "first_name" validate_is_nil :last_name validate_is_nil :age validate_is_nil "born" validate_is_nil :value end class BlankTest < Granite::Base connection {{ adapter_literal }} column id : Int64, primary: true column first_name_not_blank : String? column last_name_not_blank : String? column first_name_is_blank : String? column last_name_is_blank : String? validate_not_blank "first_name_not_blank" validate_not_blank "last_name_not_blank" validate_is_blank "first_name_is_blank" validate_is_blank "last_name_is_blank" end class ChoiceTest < Granite::Base connection {{ adapter_literal }} column id : Int64, primary: true column number_symbol : Int32? column type_array_symbol : String? column number_string : Int32? column type_array_string : String? validate_is_valid_choice :number_symbol, [1, 2, 3] validate_is_valid_choice :type_array_symbol, [:internal, :external, :third_party] validate_is_valid_choice "number_string", [4, 5, 6] validate_is_valid_choice "type_array_string", ["internal", "external", "third_party"] end class LessThanTest < Granite::Base connection {{ adapter_literal }} column id : Int64, primary: true column int_32_lt : Int32? column float_32_lt : Float32? column int_32_lte : Int32? column float_32_lte : Float32? validate_less_than "int_32_lt", 10 validate_less_than :float_32_lt, 20.5 validate_less_than :int_32_lte, 50, true validate_less_than "float_32_lte", 100.25, true end class GreaterThanTest < Granite::Base connection {{ adapter_literal }} column id : Int64, primary: true column int_32_lt : Int32? column float_32_lt : Float32? column int_32_lte : Int32? column float_32_lte : Float32? validate_greater_than "int_32_lt", 10 validate_greater_than :float_32_lt, 20.5 validate_greater_than :int_32_lte, 50, true validate_greater_than "float_32_lte", 100.25, true end class LengthTest < Granite::Base connection {{ adapter_literal }} column id : Int64, primary: true column title : String? column description : String? validate_min_length :title, 5 validate_max_length :description, 25 end class PersonUniqueness < Granite::Base connection {{ adapter_literal }} column id : Int64, primary: true column name : String? validate_uniqueness :name end class ExclusionTest < Granite::Base connection {{ adapter_literal }} column id : Int64, primary: true column name : String? validate_exclusion :name, ["test_name"] end end {% end %} {% for model in Granite::Base.all_subclasses %} {{model.id}}.migrator.drop_and_create {% end %} ================================================ FILE: src/adapter/base.cr ================================================ require "../granite" require "db" require "colorize" # The Base Adapter specifies the interface that will be used by the model # objects to perform actions against a specific database. Each adapter needs # to implement these methods. abstract class Granite::Adapter::Base getter name : String getter url : String private property _database : DB::Database? private SQL_KEYWORDS = Set(String).new(%w( ALTER AND ANY AS ASC COLUMN CONSTRAINT COUNT CREATE DEFAULT DELETE DESC DISTINCT DROP ELSE EXISTS FALSE FOREIGN FROM GROUP HAVING IF IN INDEX INNER INSERT INTO JOIN LIMIT NOT NULL ON OR ORDER PRIMARY REFERENCES RELEASE RETURNING SELECT SET TABLE THEN TRUE UNION UNIQUE UPDATE USING VALUES WHEN WHERE )) def initialize(@name : String, @url : String) end def database : DB::Database @_database ||= DB.open(@url) end def open(&) database.retry do database.using_connection do |conn| yield conn rescue ex : IO::Error raise ::DB::ConnectionLost.new(conn) rescue ex : Exception if ex.message =~ /client was disconnected/ raise ::DB::ConnectionLost.new(conn) else raise ex end end end end def log(query : String, elapsed_time : Time::Span, params = [] of String) : Nil Log.debug { colorize query, params, elapsed_time.total_seconds } end # remove all rows from a table and reset the counter on the id. abstract def clear(table_name : String) # select performs a query against a table. The query object contains table_name, # fields (configured using the sql_mapping directive in your model), and an optional # raw query string. The clause and params is the query and params that is passed # in via .all() method def select(query : Granite::Select::Container, clause = "", params = [] of DB::Any, &) clause = ensure_clause_template(clause) statement = query.custom ? "#{query.custom} #{clause}" : String.build do |stmt| stmt << "SELECT " stmt << query.fields.map { |name| "#{quote(query.table_name)}.#{quote(name)}" }.join(", ") stmt << " FROM #{quote(query.table_name)} #{clause}" end elapsed_time = Time.measure do open do |db| db.query statement, args: params do |rs| yield rs end end end log statement, elapsed_time, params end # Returns `true` if a record exists that matches *criteria*, otherwise `false`. def exists?(table_name : String, criteria : String, params = [] of Granite::Columns::Type) : Bool statement = "SELECT EXISTS(SELECT 1 FROM #{table_name} WHERE #{ensure_clause_template(criteria)})" exists = false elapsed_time = Time.measure do open do |db| exists = db.query_one?(statement, args: params, as: Bool) || exists end end log statement, elapsed_time, params exists end protected def ensure_clause_template(clause : String) : String clause end # This will insert a row in the database and return the id generated. abstract def insert(table_name : String, fields, params, lastval) : Int64 # This will insert an array of models as one insert statement abstract def import(table_name : String, primary_name : String, auto : Bool, fields, model_array, **options) # This will update a row in the database. abstract def update(table_name : String, primary_name : String, fields, params) # This will delete a row from the database. abstract def delete(table_name : String, primary_name : String, value) module Schema TYPES = { "Bool" => "BOOL", "Float32" => "FLOAT", "Float64" => "REAL", "Int32" => "INT", "Int64" => "BIGINT", "String" => "VARCHAR(255)", "Time" => "TIMESTAMP", } end # Use macro in order to read a constant defined in each subclasses. macro inherited # quotes table and column names def quote(name : String) : String String.build do |str| str << QUOTING_CHAR str << name str << QUOTING_CHAR end end # converts the crystal class to database type of this adapter def self.schema_type?(key : String) : String? Schema::TYPES[key]? || Granite::Adapter::Base::Schema::TYPES[key]? end end private def colorize(query : String, params, elapsed_time : Float64) : String q = query.to_s.split(/([a-zA-Z0-9_$']+)/).map do |word| if SQL_KEYWORDS.includes?(word.upcase) word.colorize.bold.blue.to_s elsif !word.starts_with?('$') && word =~ /\d+/ word.colorize.light_red elsif word.starts_with?('\'') && word.ends_with?('\'') word.colorize(Colorize::Color256.new(193)) else word.colorize.white end end.join "[#{humanize_duration(elapsed_time)}] #{q}: #{params.colorize.light_magenta}" end private def humanize_duration(elapsed_time : Float64) if elapsed_time > 0.1 "#{(elapsed_time).*(100).trunc./(100)}s".colorize.red elsif elapsed_time > 0.001 "#{(elapsed_time * 1_000).trunc}ms".colorize.yellow elsif elapsed_time > 0.000_001 "#{(elapsed_time * 1_000_000).trunc}µs".colorize.green elsif elapsed_time > 0.000_000_001 "#{(elapsed_time * 1_000_000_000).trunc}ns".colorize.green else "<1ns".colorize.green end end end ================================================ FILE: src/adapter/mysql.cr ================================================ require "./base" require "mysql" # Mysql implementation of the Adapter class Granite::Adapter::Mysql < Granite::Adapter::Base QUOTING_CHAR = '`' module Schema TYPES = { "AUTO_Int32" => "INT NOT NULL AUTO_INCREMENT", "AUTO_Int64" => "BIGINT NOT NULL AUTO_INCREMENT", "AUTO_UUID" => "CHAR(36)", "Float64" => "DOUBLE", "UUID" => "CHAR(36)", "created_at" => "TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP", "updated_at" => "TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP", } end # Using TRUNCATE instead of DELETE so the id column resets to 0 def clear(table_name : String) statement = "TRUNCATE #{quote(table_name)}" elapsed_time = Time.measure do open do |db| db.exec statement end end log statement, elapsed_time end def insert(table_name : String, fields, params, lastval) : Int64 statement = String.build do |stmt| stmt << "INSERT INTO #{quote(table_name)} (" stmt << fields.map { |name| "#{quote(name)}" }.join(", ") stmt << ") VALUES (" stmt << fields.map { |_name| "?" }.join(", ") stmt << ")" end last_id = -1_i64 elapsed_time = Time.measure do open do |conn| conn.exec statement, args: params last_id = conn.scalar(last_val()).as(Int64) if lastval end end log statement, elapsed_time, params last_id end def import(table_name : String, primary_name : String, auto : Bool, fields, model_array, **options) params = [] of Granite::Columns::Type statement = String.build do |stmt| stmt << "INSERT" stmt << " IGNORE" if options["ignore_on_duplicate"]? stmt << " INTO #{quote(table_name)} (" stmt << fields.map { |field| quote(field) }.join(", ") stmt << ") VALUES " model_array.each do |model| model.set_timestamps next unless model.valid? stmt << "(" stmt << Array.new(fields.size, '?').join(',') params.concat fields.map { |field| model.read_attribute field } stmt << ")," end end.chomp(',') if options["update_on_duplicate"]? if columns = options["columns"]? statement += " ON DUPLICATE KEY UPDATE " columns << "updated_at" if fields.includes? "updated_at" columns.each do |key| statement += "#{quote(key)}=VALUES(#{quote(key)}), " end statement = statement.chomp(", ") end end elapsed_time = Time.measure do open do |db| db.exec statement, args: params end end log statement, elapsed_time, params end private def last_val : String "SELECT LAST_INSERT_ID()" end # This will update a row in the database. def update(table_name : String, primary_name : String, fields, params) statement = String.build do |stmt| stmt << "UPDATE #{quote(table_name)} SET " stmt << fields.map { |name| "#{quote(name)}=?" }.join(", ") stmt << " WHERE #{quote(primary_name)}=?" end elapsed_time = Time.measure do open do |db| db.exec statement, args: params end end log statement, elapsed_time, params end # This will delete a row from the database. def delete(table_name : String, primary_name : String, value) statement = "DELETE FROM #{quote(table_name)} WHERE #{quote(primary_name)}=?" elapsed_time = Time.measure do open do |db| db.exec statement, value end end log statement, elapsed_time, value end end ================================================ FILE: src/adapter/pg.cr ================================================ require "./base" require "pg" # PostgreSQL implementation of the Adapter class Granite::Adapter::Pg < Granite::Adapter::Base QUOTING_CHAR = '"' module Schema TYPES = { "Float32" => "REAL", "Float64" => "DOUBLE PRECISION", "String" => "TEXT", "AUTO_Int32" => "SERIAL", "AUTO_Int64" => "BIGSERIAL", "AUTO_UUID" => "UUID", "UUID" => "UUID", "created_at" => "TIMESTAMP", "updated_at" => "TIMESTAMP", "Array(String)" => "TEXT[]", "Array(Int16)" => "SMALLINT[]", "Array(Int32)" => "INT[]", "Array(Int64)" => "BIGINT[]", "Array(Float32)" => "REAL[]", "Array(Float64)" => "DOUBLE PRECISION[]", "Array(Bool)" => "BOOLEAN[]", } end # remove all rows from a table and reset the counter on the id. def clear(table_name : String) statement = "DELETE FROM #{quote(table_name)}" elapsed_time = Time.measure do open do |db| db.exec statement end end log statement, elapsed_time end def insert(table_name : String, fields, params, lastval) : Int64 statement = String.build do |stmt| stmt << "INSERT INTO #{quote(table_name)} (" stmt << fields.map { |name| "#{quote(name)}" }.join(", ") stmt << ") VALUES (" stmt << position_str(fields.size) stmt << ")" stmt << " RETURNING #{quote(lastval)}" if lastval end last_id = -1_i64 elapsed_time = Time.measure do open do |db| if lastval last_id = db.scalar(statement, args: params).as(Int32 | Int64).to_i64 else db.exec statement, args: params end end end log statement, elapsed_time, params last_id end def import(table_name : String, primary_name : String, auto : Bool, fields, model_array, **options) params = [] of Granite::Columns::Type # PG fails when inserting null into AUTO INCREMENT PK field. # If AUTO INCREMENT is TRUE AND all model's pk are nil, remove PK from fields list for AUTO INCREMENT to work properly fields.reject! { |field| field == primary_name } if model_array.all? { |m| m.to_h[primary_name].nil? } && auto index = 0 statement = String.build do |stmt| stmt << "INSERT" stmt << " INTO #{quote(table_name)} (" stmt << fields.map { |field| quote(field) }.join(", ") stmt << ") VALUES " model_array.each do |model| model.set_timestamps next unless model.valid? stmt << '(' stmt << fields.map_with_index { |_f, idx| "$#{index + idx + 1}" }.join(',') params.concat fields.map { |field| model.read_attribute field } stmt << ")," index += fields.size end end.chomp(',') if options["update_on_duplicate"]? if columns = options["columns"]? statement += " ON CONFLICT (#{quote(primary_name)}) DO UPDATE SET " columns << "updated_at" if fields.includes? "updated_at" columns.each do |key| statement += "#{quote(key)}=EXCLUDED.#{quote(key)}, " end end statement = statement.chomp(", ") elsif options["ignore_on_duplicate"]? statement += " ON CONFLICT DO NOTHING" end elapsed_time = Time.measure do open do |db| db.exec statement, args: params end end log statement, elapsed_time, params end # This will update a row in the database. def update(table_name : String, primary_name : String, fields, params) statement = String.build do |stmt| stmt << "UPDATE #{quote(table_name)} SET " stmt << fields.map_with_index { |name, i| "#{quote(name)}=$#{i + 1}" }.join(", ") stmt << " WHERE #{quote(primary_name)}=$#{fields.size + 1}" end elapsed_time = Time.measure do open do |db| db.exec statement, args: params end end log statement, elapsed_time, params end # This will delete a row from the database. def delete(table_name : String, primary_name : String, value) statement = "DELETE FROM #{quote(table_name)} WHERE #{quote(primary_name)}=$1" elapsed_time = Time.measure do open do |db| db.exec statement, value end end log statement, elapsed_time, value end protected def ensure_clause_template(clause : String) : String if clause.includes?("?") num_subs = clause.count("?") num_subs.times do |i| clause = clause.sub("?", "$#{i + 1}") end end clause end private def position_str(n : Int32) : String i = 1 String.build do |str| while i <= n str << "$" << i i += 1 str << ", " if i <= n end end end end ================================================ FILE: src/adapter/sqlite.cr ================================================ require "./base" require "sqlite3" # Sqlite implementation of the Adapter class Granite::Adapter::Sqlite < Granite::Adapter::Base QUOTING_CHAR = '"' module Schema TYPES = { "AUTO_Int32" => "INTEGER NOT NULL", "AUTO_Int64" => "INTEGER NOT NULL", "AUTO_UUID" => "CHAR(36)", "UUID" => "CHAR(36)", "Int32" => "INTEGER", "Int64" => "INTEGER", "created_at" => "VARCHAR", "updated_at" => "VARCHAR", } end # remove all rows from a table and reset the counter on the id. def clear(table_name : String) statement = "DELETE FROM #{quote(table_name)}" elapsed_time = Time.measure do open do |db| db.exec statement end end log statement, elapsed_time end def insert(table_name : String, fields, params, lastval) : Int64 statement = String.build do |stmt| stmt << "INSERT INTO #{quote(table_name)} (" stmt << fields.map { |name| "#{quote(name)}" }.join(", ") stmt << ") VALUES (" stmt << fields.map { |_name| "?" }.join(", ") stmt << ")" end last_id = -1_i64 elapsed_time = Time.measure do open do |db| db.exec statement, args: params last_id = db.scalar(last_val()).as(Int64) if lastval end end log statement, elapsed_time, params last_id end def import(table_name : String, primary_name : String, auto : Bool, fields, model_array, **options) params = [] of Granite::Columns::Type statement = String.build do |stmt| stmt << "INSERT " if options["update_on_duplicate"]? stmt << "OR REPLACE " elsif options["ignore_on_duplicate"]? stmt << "OR IGNORE " end stmt << "INTO #{quote(table_name)} (" stmt << fields.map { |field| quote(field) }.join(", ") stmt << ") VALUES " model_array.each do |model| next unless model.valid? model.set_timestamps stmt << '(' stmt << Array.new(fields.size, '?').join(',') params.concat fields.map { |field| model.read_attribute field } stmt << ")," end end.chomp(',') elapsed_time = Time.measure do open do |db| db.exec statement, args: params end end log statement, elapsed_time, params end private def last_val "SELECT LAST_INSERT_ROWID()" end # This will update a row in the database. def update(table_name : String, primary_name : String, fields, params) statement = String.build do |stmt| stmt << "UPDATE #{quote(table_name)} SET " stmt << fields.map { |name| "#{quote(name)}=?" }.join(", ") stmt << " WHERE #{quote(primary_name)}=?" end elapsed_time = Time.measure do open do |db| db.exec statement, args: params end end log statement, elapsed_time, params end # This will delete a row from the database. def delete(table_name : String, primary_name : String, value) statement = "DELETE FROM #{quote(table_name)} WHERE #{quote(primary_name)}=?" elapsed_time = Time.measure do open do |db| db.exec statement, value end end log statement, elapsed_time, value end end ================================================ FILE: src/granite/association_collection.cr ================================================ class Granite::AssociationCollection(Owner, Target) forward_missing_to all def initialize(@owner : Owner, @foreign_key : (Symbol | String), @through : (Symbol | String | Nil) = nil, @primary_key : (Symbol | String | Nil) = nil) end def all(clause = "", params = [] of DB::Any) Target.all( [query, clause].join(" "), [owner.primary_key_value] + params ) end def find_by(**args) Target.first( "#{query} AND #{args.map { |arg| "#{Target.quote(Target.table_name)}.#{Target.quote(arg.to_s)} = ?" }.join(" AND ")}", [owner.primary_key_value] + args.values.to_a ) end def find_by!(**args) find_by(**args) || raise Granite::Querying::NotFound.new("No #{Target.name} found where #{args.map { |k, v| "#{k} = #{v}" }.join(" and ")}") end def find(value) Target.find(value) end def find!(value) Target.find!(value) end private getter owner private getter foreign_key private getter through private def query if through.nil? "WHERE #{Target.table_name}.#{@foreign_key} = ?" else key = @primary_key || "#{Target.to_s.underscore}_id" "JOIN #{through} ON #{through}.#{key} = #{Target.table_name}.#{Target.primary_name} " \ "WHERE #{through}.#{@foreign_key} = ?" end end end ================================================ FILE: src/granite/associations.cr ================================================ module Granite::Associations macro belongs_to(model, **options) {% if model.is_a? TypeDeclaration %} {% method_name = model.var %} {% class_name = model.type %} {% else %} {% method_name = model.id %} {% class_name = options[:class_name] || model.id.camelcase %} {% end %} {% if options[:foreign_key] && options[:foreign_key].is_a? TypeDeclaration %} {% foreign_key = options[:foreign_key].var %} column {{options[:foreign_key]}}{% if options[:primary] %}, primary: {{options[:primary]}}{% end %}{% if options[:converter] %}, converter: {{options[:converter]}}{% end %} {% else %} {% foreign_key = method_name + "_id" %} column {{foreign_key}} : Int64?{% if options[:primary] %}, primary: {{options[:primary]}}{% end %}{% if options[:converter] %}, converter: {{options[:converter]}}{% end %} {% end %} {% primary_key = options[:primary_key] || "id" %} @[Granite::Relationship(target: {{class_name.id}}, type: :belongs_to, primary_key: {{primary_key.id}}, foreign_key: {{foreign_key.id}})] def {{method_name.id}} : {{class_name.id}}? if parent = {{class_name.id}}.find_by({{primary_key.id}}: {{foreign_key.id}}) parent else {{class_name.id}}.new end end def {{method_name.id}}! : {{class_name.id}} {{class_name.id}}.find_by!({{primary_key.id}}: {{foreign_key.id}}) end def {{method_name.id}}=(parent : {{class_name.id}}) @{{foreign_key.id}} = parent.{{primary_key.id}} end end macro has_one(model, **options) {% if model.is_a? TypeDeclaration %} {% method_name = model.var %} {% class_name = model.type %} {% else %} {% method_name = model.id %} {% class_name = options[:class_name] || model.id.camelcase %} {% end %} {% foreign_key = options[:foreign_key] || @type.stringify.split("::").last.underscore + "_id" %} {% if options[:primary_key] && options[:primary_key].is_a? TypeDeclaration %} {% primary_key = options[:primary_key].var %} column {{options[:primary_key]}} {% else %} {% primary_key = options[:primary_key] || "id" %} {% end %} @[Granite::Relationship(target: {{class_name.id}}, type: :has_one, primary_key: {{primary_key.id}}, foreign_key: {{foreign_key.id}})] def {{method_name}} : {{class_name}}? {{class_name.id}}.find_by({{foreign_key.id}}: self.{{primary_key.id}}) end def {{method_name}}! : {{class_name}} {{class_name.id}}.find_by!({{foreign_key.id}}: self.{{primary_key.id}}) end def {{method_name}}=(child) child.{{foreign_key.id}} = self.{{primary_key.id}} end end macro has_many(model, **options) {% if model.is_a? TypeDeclaration %} {% method_name = model.var %} {% class_name = model.type %} {% else %} {% method_name = model.id %} {% class_name = options[:class_name] || model.id.camelcase %} {% end %} {% foreign_key = options[:foreign_key] || @type.stringify.split("::").last.underscore + "_id" %} {% primary_key = options[:primary_key] || class_name.stringify.split("::").last.underscore + "_id" %} {% through = options[:through] %} @[Granite::Relationship(target: {{class_name.id}}, through: {{through.id}}, type: :has_many, primary_key: {{through}}, foreign_key: {{foreign_key.id}})] def {{method_name.id}} Granite::AssociationCollection(self, {{class_name.id}}).new(self, {{foreign_key}}, {{through}}, {{primary_key}}) end end end ================================================ FILE: src/granite/base.cr ================================================ require "./collection" require "./association_collection" require "./associations" require "./callbacks" require "./columns" require "./query/executors/base" require "./query/**" require "./settings" require "./table" require "./validators" require "./validation_helpers/**" require "./migrator" require "./select" require "./version" require "./connections" require "./integrators" require "./converters" require "./type" require "./connection_management" # Granite::Base is the base class for your model objects. abstract class Granite::Base include Associations include Callbacks include Columns include Tables include Transactions include Validators include ValidationHelpers include Migrator include Select include Querying include ConnectionManagement extend Columns::ClassMethods extend Tables::ClassMethods extend Granite::Migrator::ClassMethods extend Querying::ClassMethods extend Query::BuilderMethods extend Transactions::ClassMethods extend Integrators extend Select macro inherited protected class_getter select_container : Container = Container.new(table_name: table_name, fields: fields) include JSON::Serializable include YAML::Serializable # Returns true if this object hasn't been saved yet. @[JSON::Field(ignore: true)] @[YAML::Field(ignore: true)] disable_granite_docs? property? new_record : Bool = true # Returns true if this object has been destroyed. @[JSON::Field(ignore: true)] @[YAML::Field(ignore: true)] disable_granite_docs? getter? destroyed : Bool = false # Returns true if the record is persisted. disable_granite_docs? def persisted? !(new_record? || destroyed?) end disable_granite_docs? def initialize(**args : Granite::Columns::Type) set_attributes(args.to_h.transform_keys(&.to_s)) end disable_granite_docs? def initialize(args : Granite::ModelArgs) set_attributes(args.transform_keys(&.to_s)) end disable_granite_docs? def initialize end before_save :switch_to_writer_adapter before_destroy :switch_to_writer_adapter after_save :update_last_write_time after_save :schedule_adapter_switch after_destroy :update_last_write_time after_destroy :schedule_adapter_switch end end ================================================ FILE: src/granite/callbacks.cr ================================================ module Granite::Callbacks class Abort < Exception end CALLBACK_NAMES = %w(before_save after_save before_create after_create before_update after_update before_destroy after_destroy) @[JSON::Field(ignore: true)] @[YAML::Field(ignore: true)] @_current_callback : String? macro included macro inherited disable_granite_docs? CALLBACKS = { {% for name in CALLBACK_NAMES %} {{name.id}}: [] of Nil, {% end %} } {% for name in CALLBACK_NAMES %} disable_granite_docs? def {{name.id}} __{{name.id}} end {% end %} end end {% for name in CALLBACK_NAMES %} macro {{name.id}}(*callbacks, &block) \{% for callback in callbacks %} \{% CALLBACKS[{{name}}] << callback %} \{% end %} \{% if block.is_a? Block %} \{% CALLBACKS[{{name}}] << block %} \{% end %} end macro __{{name.id}} @_current_callback = {{name}} \{% for callback in CALLBACKS[{{name}}] %} \{% if callback.is_a? Block %} begin \{{callback.body}} end \{% else %} \{{callback.id}} \{% end %} \{% end %} end {% end %} def abort!(message = "Aborted at #{@_current_callback}.") raise Abort.new(message) end end ================================================ FILE: src/granite/collection.cr ================================================ class Granite::Collection(M) forward_missing_to collection def initialize(@loader : -> Array(M)) @loaded = false @collection = [] of M end def loaded? @loaded end private getter loader private def collection return @collection if loaded? @collection = loader.call @loaded = true @collection end end ================================================ FILE: src/granite/columns.cr ================================================ require "json" require "uuid" module Granite::Columns alias SupportedArrayTypes = Array(String) | Array(Int16) | Array(Int32) | Array(Int64) | Array(Float32) | Array(Float64) | Array(Bool) | Array(UUID) alias Type = DB::Any | SupportedArrayTypes | UUID module ClassMethods # All fields def fields : Array(String) {% begin %} {% columns = @type.instance_vars.select(&.annotation(Granite::Column)).map(&.name.stringify) %} {{columns.empty? ? "[] of String".id : columns}} {% end %} end # Columns minus the PK def content_fields : Array(String) {% begin %} {% columns = @type.instance_vars.select { |ivar| (ann = ivar.annotation(Granite::Column)) && !ann[:primary] }.map(&.name.stringify) %} {{columns.empty? ? "[] of String".id : columns}} {% end %} end end def content_values : Array(Granite::Columns::Type) parsed_params = [] of Type {% for column in @type.instance_vars.select { |ivar| (ann = ivar.annotation(Granite::Column)) && !ann[:primary] } %} {% ann = column.annotation(Granite::Column) %} parsed_params << {% if ann[:converter] %} {{ann[:converter]}}.to_db {{column.name.id}} {% else %} {{column.name.id}} {% end %} {% end %} parsed_params end # Consumes the result set to set self's property values. def from_rs(result : DB::ResultSet) : Nil {% begin %} result.column_names.each do |col| case col {% for column in @type.instance_vars.select(&.annotation(Granite::Column)) %} {% ann = column.annotation(Granite::Column) %} when {{column.name.stringify}} @{{column.id}} = {% if ann[:converter] %} {{ann[:converter]}}.from_rs result {% else %} value = Granite::Type.from_rs(result, {{ann[:nilable] ? column.type : column.type.union_types.reject { |t| t == Nil }.first}}) {% if column.has_default_value? && !column.default_value.nil? %} return {{column.default_value}} if value.nil? {% end %} value {% end %} {% end %} else # Skip end end {% end %} end # Defines a column *decl* with the given *options*. macro column(decl, **options) {% type = decl.type %} {% not_nilable_type = type.is_a?(Path) ? type.resolve : (type.is_a?(Union) ? type.types.reject(&.resolve.nilable?).first : (type.is_a?(Generic) ? type.resolve : type)) %} # Raise an exception if the delc type has more than 2 union types or if it has 2 types without nil # This prevents having a column typed to String | Int32 etc. {% if type.is_a?(Union) && (type.types.size > 2 || (type.types.size == 2 && !type.types.any?(&.resolve.nilable?))) %} {% raise "The column #{@type.name}##{decl.var} cannot consist of a Union with a type other than `Nil`." %} {% end %} {% column_type = (options[:column_type] && !options[:column_type].nil?) ? options[:column_type] : nil %} {% converter = (options[:converter] && !options[:converter].nil?) ? options[:converter] : nil %} {% primary = (options[:primary] && !options[:primary].nil?) ? options[:primary] : false %} {% auto = (options[:auto] && !options[:auto].nil?) ? options[:auto] : false %} {% auto = (!options || (options && options[:auto] == nil)) && primary %} {% nilable = (type.is_a?(Path) ? type.resolve.nilable? : (type.is_a?(Union) ? type.types.any?(&.resolve.nilable?) : (type.is_a?(Generic) ? type.resolve.nilable? : type.nilable?))) %} @[Granite::Column(column_type: {{column_type}}, converter: {{converter}}, auto: {{auto}}, primary: {{primary}}, nilable: {{nilable}})] @{{decl.var}} : {{decl.type}}? {% unless decl.value.is_a? Nop %} = {{decl.value}} {% end %} {% if nilable || primary %} def {{decl.var.id}}=(@{{decl.var.id}} : {{not_nilable_type}}?); end def {{decl.var.id}} : {{not_nilable_type}}? @{{decl.var}} end def {{decl.var.id}}! : {{not_nilable_type}} raise NilAssertionError.new {{@type.name.stringify}} + "#" + {{decl.var.stringify}} + " cannot be nil" if @{{decl.var}}.nil? @{{decl.var}}.not_nil! end {% else %} def {{decl.var.id}}=(@{{decl.var.id}} : {{type.id}}); end def {{decl.var.id}} : {{type.id}} raise NilAssertionError.new {{@type.name.stringify}} + "#" + {{decl.var.stringify}} + " cannot be nil" if @{{decl.var}}.nil? @{{decl.var}}.not_nil! end {% end %} end # include created_at and updated_at that will automatically be updated macro timestamps column created_at : Time? column updated_at : Time? end def to_h fields = {{"Hash(String, Union(#{@type.instance_vars.select(&.annotation(Granite::Column)).map(&.type.id).splat})).new".id}} {% for column in @type.instance_vars.select(&.annotation(Granite::Column)) %} {% nilable = (column.type.is_a?(Path) ? column.type.resolve.nilable? : (column.type.is_a?(Union) ? column.type.types.any?(&.resolve.nilable?) : (column.type.is_a?(Generic) ? column.type.resolve.nilable? : column.type.nilable?))) %} begin {% if column.type.id == Time.id %} fields["{{column.name}}"] = {{column.name.id}}.try(&.in(Granite.settings.default_timezone).to_s(Granite::DATETIME_FORMAT)) {% elsif column.type.id == Slice.id %} fields["{{column.name}}"] = {{column.name.id}}.try(&.to_s("")) {% else %} fields["{{column.name}}"] = {{column.name.id}} {% end %} rescue ex : NilAssertionError {% if nilable %} fields["{{column.name}}"] = nil {% end %} end {% end %} fields end def set_attributes(hash : Hash(String | Symbol, T)) : self forall T {% for column in @type.instance_vars.select { |ivar| (ann = ivar.annotation(Granite::Column)) && (!ann[:primary] || (ann[:primary] && ann[:auto] == false)) } %} if hash.has_key?({{column.stringify}}) begin val = Granite::Type.convert_type hash[{{column.stringify}}], {{column.type}} rescue ex : ArgumentError error = Granite::ConversionError.new({{column.name.stringify}}, ex.message) end if !val.is_a? {{column.type}} error = Granite::ConversionError.new({{column.name.stringify}}, "Expected {{column.id}} to be {{column.type}} but got #{typeof(val)}.") else @{{column}} = val end errors << error if error end {% end %} self end def read_attribute(attribute_name : Symbol | String) : Type {% begin %} case attribute_name.to_s {% for column in @type.instance_vars.select(&.annotation(Granite::Column)) %} {% ann = column.annotation(Granite::Column) %} when "{{ column.name }}" {% if ann[:converter] %} {{ann[:converter]}}.to_db @{{column.name.id}} {% else %} @{{ column.name.id }} {% end %} {% end %} else raise "Cannot read attribute #{attribute_name}, invalid attribute" end {% end %} end def primary_key_value {% begin %} {% primary_key = @type.instance_vars.find { |ivar| (ann = ivar.annotation(Granite::Column)) && ann[:primary] } %} {% raise raise "A primary key must be defined for #{@type.name}." unless primary_key %} {{primary_key.id}} {% end %} end end ================================================ FILE: src/granite/connection_management.cr ================================================ module Granite::ConnectionManagement macro included # Default value for the time a model waits before using a reader # database connection for read operations # all models use this value. Change it # to change it in all Granite::Base models. class_property connection_switch_wait_period : Int32 = Granite::Connections.connection_switch_wait_period @@last_write_time = Time.monotonic class_property current_adapter : Granite::Adapter::Base? class_property reader_adapter : Granite::Adapter::Base? class_property writer_adapter : Granite::Adapter::Base? def self.last_write_time @@last_write_time end # This is done this way because callbacks don't work on class mthods def self.update_last_write_time @@last_write_time = Time.monotonic end def update_last_write_time self.class.update_last_write_time end def self.time_since_last_write Time.monotonic - @@last_write_time end def time_since_last_write self.class.time_since_last_write end def self.switch_to_reader_adapter if time_since_last_write > @@connection_switch_wait_period.milliseconds @@current_adapter = @@reader_adapter end end def switch_to_reader_adapter self.class.switch_to_reader_adapter end def self.switch_to_writer_adapter @@current_adapter = @@writer_adapter end def switch_to_writer_adapter self.class.switch_to_writer_adapter end def self.schedule_adapter_switch return if @@writer_adapter == @@reader_adapter spawn do sleep @@connection_switch_wait_period.milliseconds switch_to_reader_adapter end Fiber.yield end def schedule_adapter_switch self.class.schedule_adapter_switch end def self.adapter begin @@current_adapter.not_nil! rescue NilAssertionError Granite::Connections.registered_connections.first?.not_nil![:writer] end end end macro connection(name) {% name = name.id.stringify %} error_message = "Connection #{{{name}}} not found in Granite::Connections. Available connections are: #{Granite::Connections.registered_connections.map{ |conn| "#{conn[:writer].name}"}.join(", ")}" raise error_message if Granite::Connections[{{name}}].nil? self.writer_adapter = Granite::Connections[{{name}}].not_nil![:writer] self.reader_adapter = Granite::Connections[{{name}}].not_nil![:reader] self.current_adapter = @@writer_adapter end end ================================================ FILE: src/granite/connections.cr ================================================ module Granite class Connections class_property connection_switch_wait_period : Int32 = 2000 class_getter registered_connections = [] of {writer: Granite::Adapter::Base, reader: Granite::Adapter::Base} # Registers the given *adapter*. Raises if an adapter with the same name has already been registered. def self.<<(adapter : Granite::Adapter::Base) : Nil raise "Adapter with name '#{adapter.name}' has already been registered." if @@registered_connections.any? { |conn| conn[:writer].name == adapter.name } @@registered_connections << {writer: adapter, reader: adapter} end def self.<<(data : NamedTuple(name: String, reader: String, writer: String, adapter_type: Granite::Adapter::Base.class)) : Nil raise "Adapter with name '#{data[:name]}' has already been registered." if @@registered_connections.any? { |conn| conn[:writer].name == data[:name] } writer_adapter = data[:adapter_type].new(name: data[:name], url: data[:writer]) # if reader/writer reference the same db. Make them point to the same granite adapter. # This avoids connection pool duplications on the same database. if data[:reader] == data[:writer] return @@registered_connections << {writer: writer_adapter, reader: writer_adapter} end reader_adapter = data[:adapter_type].new(name: data[:name], url: data[:reader]) @@registered_connections << {writer: writer_adapter, reader: reader_adapter} end # Returns a registered connection with the given *name*, otherwise `nil`. def self.[](name : String) : {writer: Granite::Adapter::Base, reader: Granite::Adapter::Base}? registered_connections.find { |conn| conn[:writer].name == name } end def self.first_connection first_connection = @@registered_connections.first? raise "First registered connection cannot be nil" if first_connection.nil? first_connection end def self.first_writer : Granite::Adapter::Base first_connection[:writer] end def self.first_reader : Granite::Adapter::Base first_connection[:reader] end end end ================================================ FILE: src/granite/converters.cr ================================================ module Granite::Converters # Converts a `UUID` to/from a database column of type `T`. # # Valid types for `T` include: `String`, and `Bytes`. module Uuid(T) extend self def to_db(value : ::UUID?) : Granite::Columns::Type return nil if value.nil? {% if T == String %} value.to_s {% elsif T == Bytes %} # we need a heap allocated slice v = value.bytes.each.to_a Slice.new(v.to_unsafe, v.size) {% else %} {% raise "#{@type.name}#to_db does not support #{T} yet." %} {% end %} end def from_rs(result : ::DB::ResultSet) : ::UUID? value = result.read(T?) return nil if value.nil? {% if T == String || T == Bytes %} ::UUID.new value {% else %} {% raise "#{@type.name}#from_rs does not support #{T} yet." %} {% end %} end end # Converts an Enum of type `E` to/from a database column of type `T`. # # Valid types for `T` include: `Number`, `String`, and `Bytes`. module Enum(E, T) extend self def to_db(value : E?) : Granite::Columns::Type return nil if value.nil? {% if T <= Number %} value.to_i64 {% elsif T == String || T == Bytes %} value.to_s {% else %} {% raise "#{@type.name}#to_db does not support #{T} yet." %} {% end %} end def from_rs(result : ::DB::ResultSet) : E? value = result.read(T?) return nil if value.nil? {% if T <= Number %} E.from_value? value.to_i64 {% elsif T == String %} E.parse? value {% elsif T == Bytes %} E.parse? String.new value {% else %} {% raise "#{@type.name}#from_rs does not support #{T} yet." %} {% end %} end end # Converts an `Object` of type `M` to/from a database column of type `T`. # # Valid types for `T` include: `String`, `JSON::Any`, and `Bytes`. # # NOTE: `M` must implement `#to_json` and `.from_json` methods. module Json(M, T) extend self def to_db(value : M?) : Granite::Columns::Type return nil if value.nil? {% if T == String || T == JSON::Any %} value.to_json {% elsif T == Bytes %} value.to_json.to_slice {% else %} {% raise "#{@type.name}#to_db does not support #{T} yet." %} {% end %} end def from_rs(result : ::DB::ResultSet) : M? value = result.read(T?) return nil if value.nil? {% if T == JSON::Any %} M.from_json(value.to_json) {% elsif T == String %} M.from_json value {% elsif T == Bytes %} M.from_json String.new value {% else %} {% raise "#{@type.name}#from_rs does not support #{T} yet." %} {% end %} end end # Converters a `PG::Numeric` value into a `Float64`. module PgNumeric extend self def self.to_db(value) : Granite::Columns::Type value ? value : nil end def self.from_rs(result : ::DB::ResultSet) : Float64? result.read(::PG::Numeric?).try &.to_f end end end ================================================ FILE: src/granite/error.cr ================================================ class Granite::Error property field, message def initialize(@field : (String | Symbol | JSON::Any), @message : String? = "") end def to_json(builder : JSON::Builder) builder.object do builder.field "field", @field builder.field "message", @message end end def to_s(io) if field == :base io << message else io << field.to_s.capitalize << " " << message end end end class Granite::ConversionError < Granite::Error end ================================================ FILE: src/granite/exceptions.cr ================================================ module Granite class RecordNotSaved < ::Exception getter model : Granite::Base def initialize(class_name : String, @model : Granite::Base) super("Could not process #{class_name}: #{model.errors.first.message}") end end class RecordNotDestroyed < ::Exception getter model : Granite::Base def initialize(class_name : String, @model : Granite::Base) super("Could not destroy #{class_name}: #{model.errors.first.message}") end end end ================================================ FILE: src/granite/integrators.cr ================================================ require "./transactions" require "./querying" module Granite::Integrators include Transactions::ClassMethods include Querying def find_or_create_by(**args) find_by(**args) || create(**args) end def find_or_initialize_by(**args) find_by(**args) || new(**args) end end ================================================ FILE: src/granite/migrator.cr ================================================ require "./error" # DB migration tool that prepares a table for the class # # ``` # class User < Granite::Base # adapter mysql # field name : String # end # # User.migrator.drop_and_create # # => "DROP TABLE IF EXISTS `users`;" # # => "CREATE TABLE `users` (id BIGSERIAL PRIMARY KEY, name VARCHAR(255));" # # User.migrator(table_options: "ENGINE=InnoDB DEFAULT CHARSET=utf8").create # # => "CREATE TABLE ... ENGINE=InnoDB DEFAULT CHARSET=utf8;" # ``` module Granite::Migrator module ClassMethods def migrator(**args) Migrator(self).new(**args) end end class Migrator(Model) def initialize(@table_options = "") end def drop_and_create drop create end def drop_sql "DROP TABLE IF EXISTS #{Model.quoted_table_name};" end def drop Model.exec drop_sql end def create_sql resolve = ->(key : String) { Model.adapter.class.schema_type?(key) || raise "Migrator(#{Model.adapter.class.name}) doesn't support '#{key}' yet." } String.build do |s| s.puts "CREATE TABLE #{Model.quoted_table_name}(" # primary key {% begin %} {% primary_key = Model.instance_vars.find { |ivar| (ann = ivar.annotation(Granite::Column)) && ann[:primary] } %} {% raise raise "A primary key must be defined for #{Model.name}." unless primary_key %} {% ann = primary_key.annotation(Granite::Column) %} k = Model.adapter.quote("{{primary_key.name}}") v = {% if ann[:auto] %} resolve.call("AUTO_{{primary_key.type.union_types.find { |t| t != Nil }.id}}") {% else %} resolve.call("{{ivar.type.union_types.find { |t| t != Nil }.id}}") {% end %} s.print "#{k} #{v} PRIMARY KEY" {% end %} # content fields {% for ivar in Model.instance_vars.select { |ivar| (ann = ivar.annotation(Granite::Column)) && !ann[:primary] } %} {% ann = ivar.annotation(Granite::Column) %} s.puts "," k = Model.adapter.quote("{{ivar.name}}") v = {% if ann[:column_type] %} "{{ann[:column_type].id}}" {% elsif ivar.name.id == "created_at" || ivar.name.id == "updated_at" %} resolve.call("{{ivar.name}}") {% elsif ann[:nilable] %} resolve.call("{{ivar.type.union_types.find { |t| t != Nil }.id}}") {% else %} resolve.call("{{ivar.type.union_types.find { |t| t != Nil }.id}}") + " NOT NULL" {% end %} s.puts "#{k} #{v}" {% end %} s.puts ") #{@table_options};" end end def create Model.exec create_sql end end end ================================================ FILE: src/granite/query/assemblers/base.cr ================================================ module Granite::Query::Assembler abstract class Base(Model) @placeholder : String = "" @where : String? @order : String? @limit : String? @offset : String? @group_by : String? def initialize(@query : Builder(Model)) @numbered_parameters = [] of Granite::Columns::Type @aggregate_fields = [] of String end abstract def add_parameter(value : Granite::Columns::Type) : String def numbered_parameters @numbered_parameters end def add_aggregate_field(name : String) @aggregate_fields << name end def table_name Model.table_name end def field_list [Model.fields].flatten.join ", " end def build_sql(&) clauses = [] of String? yield clauses clauses.compact!.join " " end def where return @where if @where clauses = ["WHERE"] @query.where_fields.each do |expression| clauses << expression[:join].to_s.upcase unless clauses.size == 1 if expression[:field]?.nil? # custom SQL expression = expression.as(NamedTuple(join: Symbol, stmt: String, value: Granite::Columns::Type)) if !expression[:value].nil? param_token = add_parameter expression[:value] clause = expression[:stmt].gsub(@placeholder, param_token) else clause = expression[:stmt] end clauses << clause else # standard where query expression = expression.as(NamedTuple(join: Symbol, field: String, operator: Symbol, value: Granite::Columns::Type)) add_aggregate_field expression[:field] if expression[:value].nil? clauses << "#{expression[:field]} IS NULL" elsif expression[:value].is_a?(Array) in_stmt = String.build do |str| str << '(' expression[:value].as(Array).each_with_index do |val, idx| case val when Bool, Number str << val else str << add_parameter val end str << ',' if expression[:value].as(Array).size - 1 != idx end str << ')' end clauses << "#{expression[:field]} #{sql_operator(expression[:operator])} #{in_stmt}" else clauses << "#{expression[:field]} #{sql_operator(expression[:operator])} #{add_parameter expression[:value]}" end end end return nil if clauses.size == 1 @where = clauses.join(" ") end def order(use_default_order = true) return @order if @order order_fields = @query.order_fields if order_fields.none? if use_default_order order_fields = default_order else return nil end end order_clauses = order_fields.map do |expression| add_aggregate_field expression[:field] if expression[:direction] == Builder::Sort::Ascending "#{expression[:field]} ASC" else "#{expression[:field]} DESC" end end @order = "ORDER BY #{order_clauses.join ", "}" end def group_by return @group_by if @group_by group_fields = @query.group_fields return nil if group_fields.none? group_clauses = group_fields.map do |expression| "#{expression[:field]}" end @group_by = "GROUP BY #{group_clauses.join ", "}" end def limit @limit ||= if limit = @query.limit "LIMIT #{limit}" end end def offset @offset ||= if offset = @query.offset "OFFSET #{offset}" end end def log(*stuff) end def default_order [{field: Model.primary_name, direction: "ASC"}] end def count : (Executor::MultiValue(Model, Int64) | Executor::Value(Model, Int64)) sql = build_sql do |s| s << "SELECT COUNT(*)" s << "FROM #{table_name}" s << where s << group_by s << order(use_default_order: false) s << limit s << offset end if group_by Executor::MultiValue(Model, Int64).new sql, numbered_parameters, default: 0_i64 else Executor::Value(Model, Int64).new sql, numbered_parameters, default: 0_i64 end end def first(n : Int32 = 1) : Executor::List(Model) sql = build_sql do |s| s << "SELECT #{field_list}" s << "FROM #{table_name}" s << where s << group_by s << order s << "LIMIT #{n}" s << offset end Executor::List(Model).new sql, numbered_parameters end def delete sql = build_sql do |s| s << "DELETE FROM #{table_name}" s << where end log sql, numbered_parameters Model.adapter.open do |db| db.exec sql, args: numbered_parameters end end def select sql = build_sql do |s| s << "SELECT #{field_list}" s << "FROM #{table_name}" s << where s << group_by s << order s << limit s << offset end Executor::List(Model).new sql, numbered_parameters end def exists? : Executor::Value(Model, Bool) sql = build_sql do |s| s << "SELECT EXISTS(SELECT 1 " s << "FROM #{table_name} " s << where s << ")" end Executor::Value(Model, Bool).new sql, numbered_parameters, default: false end OPERATORS = {"eq": "=", "gteq": ">=", "lteq": "<=", "neq": "!=", "ltgt": "<>", "gt": ">", "lt": "<", "ngt": "!>", "nlt": "!<", "in": "IN", "nin": "NOT IN", "like": "LIKE", "nlike": "NOT LIKE"} def sql_operator(operator : Symbol) : String OPERATORS[operator.to_s]? || operator.to_s end end end ================================================ FILE: src/granite/query/assemblers/mysql.cr ================================================ # Query runner which finalizes a query and runs it. # This will likely require adapter specific subclassing :[. module Granite::Query::Assembler class Mysql(Model) < Base(Model) @placeholder = "?" def add_parameter(value : Granite::Columns::Type) : String @numbered_parameters << value "?" end end end ================================================ FILE: src/granite/query/assemblers/pg.cr ================================================ # Query runner which finalizes a query and runs it. # This will likely require adapter specific subclassing :[. module Granite::Query::Assembler class Pg(Model) < Base(Model) @placeholder = "$" def add_parameter(value : Granite::Columns::Type) : String @numbered_parameters << value "$#{@numbered_parameters.size}" end end end ================================================ FILE: src/granite/query/assemblers/sqlite.cr ================================================ module Granite::Query::Assembler class Sqlite(Model) < Base(Model) @placeholder = "?" def add_parameter(value : Granite::Columns::Type) : String @numbered_parameters << value "?" end end end ================================================ FILE: src/granite/query/builder.cr ================================================ # Data structure which will allow chaining of query components, # nesting of boolean logic, etc. # # Should return self, or another instance of Builder wherever # chaining should be possible. # # Current query syntax: # - where(field: value) => "WHERE field = 'value'" # # Hopefully soon: # - Model.where(field: value).not( Model.where(field2: value2) ) # or # - Model.where(field: value).not { where(field2: value2) } # # - Model.where(field: value).or( Model.where(field3: value3) ) # or # - Model.where(field: value).or { whehre(field3: value3) } class Granite::Query::Builder(Model) enum DbType Mysql Sqlite Pg end enum Sort Ascending Descending end getter db_type : DbType getter where_fields = [] of (NamedTuple(join: Symbol, field: String, operator: Symbol, value: Granite::Columns::Type) | NamedTuple(join: Symbol, stmt: String, value: Granite::Columns::Type)) getter order_fields = [] of NamedTuple(field: String, direction: Sort) getter group_fields = [] of NamedTuple(field: String) getter offset : Int64? getter limit : Int64? def initialize(@db_type, @boolean_operator = :and) end def assembler : Assembler::Base(Model) case @db_type when DbType::Pg Assembler::Pg(Model).new self when DbType::Mysql Assembler::Mysql(Model).new self when DbType::Sqlite Assembler::Sqlite(Model).new self else raise "Database type not supported" end end def where(**matches) where(matches) end def where(matches) matches.each do |field, value| if value.is_a?(Array) and(field: field.to_s, operator: :in, value: value.compact) elsif value.is_a?(Enum) and(field: field.to_s, operator: :eq, value: value.to_s) else and(field: field.to_s, operator: :eq, value: value) end end self end def where(field : (Symbol | String), operator : Symbol, value : Granite::Columns::Type) and(field: field.to_s, operator: operator, value: value) end def where(stmt : String, value : Granite::Columns::Type = nil) and(stmt: stmt, value: value) end def and(field : (Symbol | String), operator : Symbol, value : Granite::Columns::Type) @where_fields << {join: :and, field: field.to_s, operator: operator, value: value} self end def and(stmt : String, value : Granite::Columns::Type = nil) @where_fields << {join: :and, stmt: stmt, value: value} self end def and(**matches) and(matches) end def and(matches) matches.each do |field, value| if value.is_a?(Array) and(field: field.to_s, operator: :in, value: value.compact) elsif value.is_a?(Enum) and(field: field.to_s, operator: :eq, value: value.to_s) else and(field: field.to_s, operator: :eq, value: value) end end self end def or(**matches) or(matches) end def or(matches) matches.each do |field, value| if value.is_a?(Array) or(field: field.to_s, operator: :in, value: value.compact) elsif value.is_a?(Enum) or(field: field.to_s, operator: :eq, value: value.to_s) else or(field: field.to_s, operator: :eq, value: value) end end self end def or(field : (Symbol | String), operator : Symbol, value : Granite::Columns::Type) @where_fields << {join: :or, field: field.to_s, operator: operator, value: value} self end def or(stmt : String, value : Granite::Columns::Type = nil) @where_fields << {join: :or, stmt: stmt, value: value} self end def order(field : Symbol) @order_fields << {field: field.to_s, direction: Sort::Ascending} self end def order(fields : Array(Symbol)) fields.each do |field| order field end self end def order(**dsl) order(dsl) end def order(dsl) dsl.each do |field, dsl_direction| direction = Sort::Ascending if dsl_direction == "desc" || dsl_direction == :desc direction = Sort::Descending end @order_fields << {field: field.to_s, direction: direction} end self end def group_by(field : Symbol) @group_fields << {field: field.to_s} self end def group_by(fields : Array(Symbol)) fields.each do |field| group_by field end self end def group_by(**dsl) group_by(dsl) end def group_by(dsl) dsl.each do |field| @group_fields << {field: field.to_s} end self end def offset(num) @offset = num.to_i64 self end def limit(num) @limit = num.to_i64 self end def raw_sql assembler.select.raw_sql end # TODO: replace `querying.first` with this # def first : Model? # first(1).first? # end # def first(n : Int32) : Executor::List(Model) # assembler.first(n) # end def any? : Bool !first.nil? end def delete Model.switch_to_writer_adapter assembler.delete end def select assembler.select.run end def count assembler.count end def exists? : Bool assembler.exists?.run end def size count end def reject(&) assembler.select.run.reject do |record| yield record end end def each(&) assembler.select.tap do |record_set| record_set.each do |record| yield record end end end def map(&) assembler.select.run.map do |record| yield record end end end ================================================ FILE: src/granite/query/builder_methods.cr ================================================ module Granite::Query::BuilderMethods def __builder db_type = case adapter.class.to_s when "Granite::Adapter::Pg" Granite::Query::Builder::DbType::Pg when "Granite::Adapter::Mysql" Granite::Query::Builder::DbType::Mysql else Granite::Query::Builder::DbType::Sqlite end Builder(self).new(db_type) end delegate where, order, offset, limit, to: __builder end ================================================ FILE: src/granite/query/executors/base.cr ================================================ module Granite::Query::Executor module Shared def raw_sql : String @sql end def log(*messages) messages.each { |message| Log.debug { message } } end end end ================================================ FILE: src/granite/query/executors/list.cr ================================================ module Granite::Query::Executor class List(Model) include Shared def initialize(@sql : String, @args = [] of Granite::Columns::Type) end def run : Array(Model) log @sql, @args results = [] of Model Model.adapter.open do |db| db.query @sql, args: @args do |record_set| record_set.each do results << Model.from_rs record_set end end end results end delegate :[], :first?, :first, :each, :group_by, to: :run delegate :to_s, to: :run end end ================================================ FILE: src/granite/query/executors/multi_value.cr ================================================ module Granite::Query::Executor class MultiValue(Model, Scalar) include Shared def initialize(@sql : String, @args = [] of Granite::Columns::Type, @default : Scalar = nil) end def run : Array(Scalar) log @sql, @args raise "No default provided" if @default.nil? results = [] of Scalar Model.adapter.open do |db| db.query @sql, args: @args do |record_set| record_set.each do results << record_set.read(Scalar) end end end results end delegate :to_i, :to_s, to: :run delegate :<, :>, :<=, :>=, to: :run end end ================================================ FILE: src/granite/query/executors/value.cr ================================================ module Granite::Query::Executor class Value(Model, Scalar) include Shared def initialize(@sql : String, @args = [] of Granite::Columns::Type, @default : Scalar = nil) end def run : Scalar log @sql, @args # db.scalar raises when a query returns 0 results, so I'm using query_one? # https://github.com/crystal-lang/crystal-db/blob/7d30e9f50e478cb6404d16d2ce91e639b6f9c476/src/db/statement.cr#L18 if @default.nil? raise "No default provided" else Model.adapter.open do |db| db.query_one?(@sql, args: @args, as: Scalar) || @default end end end delegate :<, :>, :<=, :>=, to: :run delegate :to_i, :to_s, to: :run end end ================================================ FILE: src/granite/querying.cr ================================================ module Granite::Querying class NotFound < Exception end module ClassMethods # Entrypoint for creating a new object from a result set. def from_rs(result : DB::ResultSet) : self model = new model.new_record = false model.from_rs result model end def raw_all(clause = "", params = [] of Granite::Columns::Type) rows = [] of self adapter.select(select_container, clause, params) do |results| results.each do rows << from_rs(results) end end rows end # All will return all rows in the database. The clause allows you to specify # a WHERE, JOIN, GROUP BY, ORDER BY and any other SQL92 compatible query to # your table. The result will be a Collection(Model) object which lazy loads # an array of instantiated instances of your Model class. # This allows you to take full advantage of the database # that you are using so you are not restricted or dummied down to support a # DSL. # Lazy load prevent running unnecessary queries from unused variables. def all(clause = "", params = [] of Granite::Columns::Type, use_primary_adapter = true) switch_to_writer_adapter if use_primary_adapter == true Collection(self).new(->{ raw_all(clause, params) }) end # First adds a `LIMIT 1` clause to the query and returns the first result def first(clause = "", params = [] of Granite::Columns::Type) all([clause.strip, "LIMIT 1"].join(" "), params, false).first? end def first!(clause = "", params = [] of Granite::Columns::Type) first(clause, params) || raise NotFound.new("No #{{{@type.name.stringify}}} found with first(#{clause})") end # find returns the row with the primary key specified. Otherwise nil. def find(value) first("WHERE #{primary_name} = ?", [value]) end # find returns the row with the primary key specified. Otherwise raises an exception. def find!(value) find(value) || raise Granite::Querying::NotFound.new("No #{{{@type.name.stringify}}} found where #{primary_name} = #{value}") end # Returns the first row found that matches *criteria*. Otherwise `nil`. def find_by(**criteria : Granite::Columns::Type) find_by criteria.to_h end # :ditto: def find_by(criteria : Granite::ModelArgs) clause, params = build_find_by_clause(criteria) first "WHERE #{clause}", params end # Returns the first row found that matches *criteria*. Otherwise raises a `NotFound` exception. def find_by!(**criteria : Granite::Columns::Type) find_by!(criteria.to_h) end # :ditto: def find_by!(criteria : Granite::ModelArgs) find_by(criteria) || raise NotFound.new("No #{{{@type.name.stringify}}} found where #{criteria.map { |k, v| %(#{k} #{v.nil? ? "is NULL" : "= #{v}"}) }.join(" and ")}") end def find_each(clause = "", params = [] of Granite::Columns::Type, batch_size limit = 100, offset = 0, &) find_in_batches(clause, params, batch_size: limit, offset: offset) do |batch| batch.each do |record| yield record end end end def find_in_batches(clause = "", params = [] of Granite::Columns::Type, batch_size limit = 100, offset = 0, &) if limit < 1 raise ArgumentError.new("batch_size must be >= 1") end loop do results = all "#{clause} LIMIT ? OFFSET ?", params + [limit, offset], false break if results.empty? yield results offset += limit end end # Returns `true` if a records exists with a PK of *id*, otherwise `false`. def exists?(id : Number | String | Nil) : Bool return false if id.nil? exec_exists "#{primary_name} = ?", [id] end # Returns `true` if a records exists that matches *criteria*, otherwise `false`. def exists?(**criteria : Granite::Columns::Type) : Bool exists? criteria.to_h end # :ditto: def exists?(criteria : Granite::ModelArgs) : Bool exec_exists *build_find_by_clause(criteria) end # count returns a count of all the records def count : Int32 scalar "SELECT COUNT(*) FROM #{quoted_table_name}", &.to_s.to_i end def exec(clause = "") switch_to_writer_adapter adapter.open(&.exec(clause)) end def query(clause = "", params = [] of Granite::Columns::Type, &) switch_to_writer_adapter adapter.open { |db| yield db.query(clause, args: params) } end def scalar(clause = "", &) switch_to_writer_adapter adapter.open { |db| yield db.scalar(clause) } end private def exec_exists(clause : String, params : Array(Granite::Columns::Type)) : Bool self.adapter.exists? quoted_table_name, clause, params end private def build_find_by_clause(criteria : Granite::ModelArgs) keys = criteria.keys criteria_hash = criteria.dup clauses = keys.map do |name| if criteria_hash.has_key?(name) && !criteria_hash[name].nil? matcher = "= ?" else matcher = "IS NULL" criteria_hash.delete name end "#{quoted_table_name}.#{quote(name.to_s)} #{matcher}" end {clauses.join(" AND "), criteria_hash.values} end end # Returns the record with the attributes reloaded from the database. # # **Note:** this method is only defined when the `Spec` module is present. # # ``` # post = Post.create(name: "Granite Rocks!", body: "Check this out.") # # record gets updated by another process # post.reload # performs another find to fetch the record again # ``` def reload {% if !@top_level.has_constant? "Spec" %} raise "#reload is a convenience method for testing only, please use #find in your application code" {% end %} self.class.find!(primary_key_value) end end ================================================ FILE: src/granite/select.cr ================================================ module Granite::Select struct Container property custom : String? getter table_name, fields def initialize(@custom = nil, @table_name = "", @fields = [] of String) end end macro select_statement(text) @@select_container.custom = {{text.strip}} def self.select : String? self.select_container.custom end end end ================================================ FILE: src/granite/settings.cr ================================================ module Granite class Settings property default_timezone : Time::Location = Time::Location.load(Granite::TIME_ZONE) def default_timezone=(name : String) @default_timezone = Time::Location.load(name) end end def self.settings @@settings ||= Settings.new end end ================================================ FILE: src/granite/table.cr ================================================ # Adds a :nodoc: to granite methods/constants if `DISABLE_GRANTE_DOCS` ENV var is true macro disable_granite_docs?(stmt) {% unless flag?(:granite_docs) %} # :nodoc: {{stmt.id}} {% else %} {{stmt.id}} {% end %} end module Granite::Tables module ClassMethods def primary_name {% begin %} {% primary_key = @type.instance_vars.find { |ivar| (ann = ivar.annotation(Granite::Column)) && ann[:primary] } %} {% if pk = primary_key %} {{pk.name.stringify}} {% end %} {% end %} end def primary_type {% begin %} {% primary_key = @type.instance_vars.find { |ivar| (ann = ivar.annotation(Granite::Column)) && ann[:primary] } %} {% if pk = primary_key %} {{pk.type}} {% end %} {% end %} end def quoted_table_name : String self.adapter.quote(table_name) end def quote(column_name) : String self.adapter.quote(column_name) end # Returns the name of the table for `self` # defaults to the model's name underscored + 's'. def table_name : String {% begin %} {% table_ann = @type.annotation(Granite::Table) %} {{table_ann && !table_ann[:name].nil? ? table_ann[:name] : @type.name.underscore.stringify.split("::").last}} {% end %} end end macro table(name) @[Granite::Table(name: {{(name.is_a?(StringLiteral) ? name : name.id.stringify) || nil}})] class ::{{@type.name.id}}; end end end ================================================ FILE: src/granite/transactions.cr ================================================ require "./exceptions" module Granite::Transactions module ClassMethods # Removes all records from a table. def clear adapter.clear table_name end # Creates a new record, and attempts to save it to the database. Returns the # newly created record. # # **NOTE**: This method still outputs the new object even when it failed to save # to the database. The only way to determine a failure is to check any errors on # the object, or to use `#create!`. def create(**args) create(args.to_h) end # Creates a new record, and attempts to save it to the database. Allows saving # the record without timestamps. Returns the newly created record. def create(args, skip_timestamps : Bool = false) instance = new instance.set_attributes(args.to_h.transform_keys(&.to_s)) instance.save(skip_timestamps: skip_timestamps) instance end # Creates a new record, and attempts to save it to the database. Returns the # newly created record. Raises `Granite::RecordNotSaved` if the save is # unsuccessful. def create!(**args) create!(args.to_h) end # Creates a new record, and attempts to save it to the database. Allows saving # the record without timestamps. Returns the newly created record. Raises # `Granite::RecordNotSaved` if the save is unsuccessful. def create!(args, skip_timestamps : Bool = false) instance = create(args, skip_timestamps) unless instance.errors.empty? raise Granite::RecordNotSaved.new(self.name, instance) end instance end # Runs an INSERT statement for all records in *model_array*. # the array must contain only one model class # invalid model records will be skipped def import(model_array : Array(self) | Granite::Collection(self), batch_size : Int32 = model_array.size) {% begin %} {% primary_key = @type.instance_vars.find { |ivar| (ann = ivar.annotation(Granite::Column)) && ann[:primary] } %} {% raise raise "A primary key must be defined for #{@type.name}." unless primary_key %} {% ann = primary_key.annotation(Granite::Column) %} fields_duplicate = fields.dup model_array.each_slice(batch_size, true) do |slice| slice.each do |i| i.before_save i.before_create end adapter.import(table_name, {{primary_key.name.stringify}}, {{ann[:auto]}}, fields_duplicate, slice) slice.each do |i| i.after_create i.after_save end end {% end %} rescue err raise DB::Error.new(err.message, cause: err) end # Runs an INSERT statement for all records in *model_array*, with options to # update any duplicate records, and provide column names. def import(model_array : Array(self) | Granite::Collection(self), update_on_duplicate : Bool, columns : Array(String), batch_size : Int32 = model_array.size) {% begin %} {% primary_key = @type.instance_vars.find { |ivar| (ann = ivar.annotation(Granite::Column)) && ann[:primary] } %} {% raise raise "A primary key must be defined for #{@type.name}." unless primary_key %} {% ann = primary_key.annotation(Granite::Column) %} fields_duplicate = fields.dup model_array.each_slice(batch_size, true) do |slice| slice.each do |i| i.before_save i.before_create end adapter.import(table_name, {{primary_key.name.stringify}}, {{ann[:auto]}}, fields_duplicate, slice, update_on_duplicate: update_on_duplicate, columns: columns) slice.each do |i| i.after_create i.after_save end end {% end %} rescue err raise DB::Error.new(err.message, cause: err) end def import(model_array : Array(self) | Granite::Collection(self), ignore_on_duplicate : Bool, batch_size : Int32 = model_array.size) {% begin %} {% primary_key = @type.instance_vars.find { |ivar| (ann = ivar.annotation(Granite::Column)) && ann[:primary] } %} {% raise raise "A primary key must be defined for #{@type.name}." unless primary_key %} {% ann = primary_key.annotation(Granite::Column) %} fields_duplicate = fields.dup model_array.each_slice(batch_size, true) do |slice| slice.each do |i| i.before_save i.before_create end adapter.import(table_name, {{primary_key.name.stringify}}, {{ann[:auto]}}, fields_duplicate, slice, ignore_on_duplicate: ignore_on_duplicate) slice.each do |i| i.after_create i.after_save end end {% end %} rescue err raise DB::Error.new(err.message, cause: err) end end # Sets the record's timestamps(created_at & updated_at) to the current time. def set_timestamps(*, to time = Time.local(Granite.settings.default_timezone), mode = :create) {% if @type.instance_vars.select { |ivar| ivar.annotation(Granite::Column) && ivar.type == Time? }.map(&.name.stringify).includes? "created_at" %} if mode == :create @created_at = time.at_beginning_of_second end {% end %} {% if @type.instance_vars.select { |ivar| ivar.annotation(Granite::Column) && ivar.type == Time? }.map(&.name.stringify).includes? "updated_at" %} @updated_at = time.at_beginning_of_second {% end %} end private def __create(skip_timestamps : Bool = false) {% begin %} {% primary_key = @type.instance_vars.find { |ivar| (ann = ivar.annotation(Granite::Column)) && ann[:primary] } %} {% raise raise "A primary key must be defined for #{@type.name}." unless primary_key %} {% raise "Composite primary keys are not yet supported for '#{@type.name}'." if @type.instance_vars.select { |ivar| ann = ivar.annotation(Granite::Column); ann && ann[:primary] }.size > 1 %} {% ann = primary_key.annotation(Granite::Column) %} set_timestamps unless skip_timestamps fields = self.class.content_fields.dup params = content_values if value = @{{primary_key.name.id}} fields << {{primary_key.name.stringify}} params << value end {% if primary_key.type == Int32? && ann[:auto] == true %} @{{primary_key.name.id}} = self.class.adapter.insert(self.class.table_name, fields, params, lastval: {{primary_key.name.stringify}}).to_i32 {% elsif primary_key.type == Int64? && ann[:auto] == true %} @{{primary_key.name.id}} = self.class.adapter.insert(self.class.table_name, fields, params, lastval: {{primary_key.name.stringify}}) {% elsif primary_key.type == UUID? && ann[:auto] == true %} # if the primary key has not been set, then do so unless fields.includes?({{primary_key.name.stringify}}) _uuid = UUID.random @{{primary_key.name.id}} = _uuid params << _uuid fields << {{primary_key.name.stringify}} end self.class.adapter.insert(self.class.table_name, fields, params, lastval: nil) {% elsif ann[:auto] == true %} {% raise "Failed to define #{@type.name}#save: Primary key must be Int(32|64) or UUID, or set `auto: false` for natural keys.\n\n column #{primary_key.name} : #{primary_key.type}, primary: true, auto: false\n" %} {% else %} if @{{primary_key.name.id}} self.class.adapter.insert(self.class.table_name, fields, params, lastval: nil) else message = "Primary key('{{primary_key.name}}') cannot be null" errors << Granite::Error.new({{primary_key.name.stringify}}, message) raise DB::Error.new end {% end %} {% end %} rescue err : DB::Error raise err rescue err raise DB::Error.new(err.message, cause: err) else self.new_record = false end private def __update(skip_timestamps : Bool = false) {% begin %} {% primary_key = @type.instance_vars.find { |ivar| (ann = ivar.annotation(Granite::Column)) && ann[:primary] } %} {% raise raise "A primary key must be defined for #{@type.name}." unless primary_key %} {% ann = primary_key.annotation(Granite::Column) %} set_timestamps(mode: :update) unless skip_timestamps fields = self.class.content_fields.dup params = content_values + [@{{primary_key.name.id}}] # Do not update created_at on update if created_at_index = fields.index("created_at") fields.delete_at created_at_index params.delete_at created_at_index end begin self.class.adapter.update(self.class.table_name, self.class.primary_name, fields, params) rescue err raise DB::Error.new(err.message, cause: err) end {% end %} end private def __destroy {% begin %} {% primary_key = @type.instance_vars.find { |ivar| (ann = ivar.annotation(Granite::Column)) && ann[:primary] } %} {% raise raise "A primary key must be defined for #{@type.name}." unless primary_key %} {% ann = primary_key.annotation(Granite::Column) %} self.class.adapter.delete(self.class.table_name, self.class.primary_name, @{{primary_key.name.id}}) @destroyed = true {% end %} end # Attempts to save the record to the database, returning `true` if successful, # and `false` if not. If the save is unsuccessful, `#errors` will be populated # with the errors which caused the save to fail. # # **NOTE**: This method can be used both on new records, and existing records. # In the case of new records, it creates the record in the database, otherwise, # it updates the record in the database. def save(*, validate : Bool = true, skip_timestamps : Bool = false) {% begin %} {% primary_key = @type.instance_vars.find { |ivar| (ann = ivar.annotation(Granite::Column)) && ann[:primary] } %} {% raise raise "A primary key must be defined for #{@type.name}." unless primary_key %} {% ann = primary_key.annotation(Granite::Column) %} return false if validate && !valid? begin __before_save if @{{primary_key.name.id}} && !new_record? __before_update __update(skip_timestamps: skip_timestamps) __after_update else __before_create __create(skip_timestamps: skip_timestamps) __after_create end __after_save rescue ex : DB::Error | Granite::Callbacks::Abort if message = ex.message Log.error { "Save Exception: #{message}" } errors << Granite::Error.new(:base, message) end return false end true {% end %} end # Same as `#save`, but raises `Granite::RecordNotSaved` if the save is unsuccessful. def save!(*, validate : Bool = true, skip_timestamps : Bool = false) save(validate: validate, skip_timestamps: skip_timestamps) || raise Granite::RecordNotSaved.new(self.class.name, self) end # Updates the record with the new data specified by *args*. Returns `true` if the # update is successful, `false` if it isn't. def update(**args) update(args.to_h) end # Updates the record with the new data specified by *args*, with the option to # not update timestamps. Returns `true` if the update is successful, `false` if # it isn't. def update(args, skip_timestamps : Bool = false) set_attributes(args.to_h.transform_keys(&.to_s)) save(skip_timestamps: skip_timestamps) end # Updates the record with the new data specified by *args*. Raises # `Granite::RecordNotSaved` if the save is unsuccessful. def update!(**args) update!(args.to_h) end # Updates the record with the new data specified by *args*, with the option to # not update timestamps. Raises `Granite::RecordNotSaved` if the save is # unsuccessful. def update!(args, skip_timestamps : Bool = false) set_attributes(args.to_h.transform_keys(&.to_s)) save!(skip_timestamps: skip_timestamps) end # Removes the record from the database. Returns `true` if successful, `false` # otherwise. def destroy begin __before_destroy __destroy __after_destroy rescue ex : DB::Error | Granite::Callbacks::Abort if message = ex.message Log.error { "Destroy Exception: #{message}" } errors << Granite::Error.new(:base, message) end return false end true end # Same as `#destroy`, but raises `Granite::RecordNotDestroyed` if unsuccessful. def destroy! destroy || raise Granite::RecordNotDestroyed.new(self.class.name, self) end # Updates the *updated_at* field to the current time, without saving other fields. # # Raises error if record hasn't been saved to the database yet. def touch(*fields) : Bool raise "Cannot touch on a new record object" unless persisted? {% begin %} fields.each do |field| case field.to_s {% for time_field in @type.instance_vars.select { |ivar| ivar.type == Time? } %} when {{time_field.stringify}} then @{{time_field.id}} = Time.local(Granite.settings.default_timezone).at_beginning_of_second {% end %} else if {{@type.instance_vars.map(&.name.stringify)}}.includes? field.to_s raise "{{@type.name}}.#{field} cannot be touched. It is not of type `Time`." else raise "Field '#{field}' does not exist on type '{{@type.name}}'." end end end {% end %} set_timestamps mode: :update save end end ================================================ FILE: src/granite/type.cr ================================================ module Granite::Type extend self # :nodoc: PRIMITIVES = { Int8 => ".read", Int16 => ".read", Int32 => ".read", Int64 => ".read", UInt8 => ".read", UInt16 => ".read", UInt32 => ".read", UInt64 => ".read", Float32 => ".read", Float64 => ".read", Bool => ".read", String => ".read", } # :nodoc: NUMERIC_TYPES = { Int8 => ".to_i8", Int16 => ".to_i16", Int32 => ".to_i", Int64 => ".to_i64", UInt8 => ".to_u8", UInt16 => ".to_u16", UInt32 => ".to_u32", UInt64 => ".to_u64", Float32 => ".to_f32", Float64 => ".to_f", } {% for type, method in PRIMITIVES %} # Converts a `DB::ResultSet` to `{{type}}`. def from_rs(result : DB::ResultSet, t : {{type}}.class) : {{type}} result{{method.id}} {{type}} end # Converts a `DB::ResultSet` to `{{type}}?`. def from_rs(result : DB::ResultSet, t : {{type}}?.class) : {{type}}? result{{method.id}} {{type}}? end # Converts an `DB::ResultSet` to `Array({{type}})`. def from_rs(result : DB::ResultSet, t : Array({{type}}).class) : Array({{type}}) result{{method.id}} Array({{type}}) end # Converts an `DB::ResultSet` to `Array({{type}})?`. def from_rs(result : DB::ResultSet, t : Array({{type}})?.class) : Array({{type}})? result{{method.id}} Array({{type}})? end {% end %} # Converts a `DB::ResultSet` to `Time`. def from_rs(result : DB::ResultSet, t : Time.class) : Time result.read(Time).in(Granite.settings.default_timezone) end # Converts a `DB::ResultSet` to `Time?` def from_rs(result : DB::ResultSet, t : Time?.class) : Time? result.read(Time?).try &.in(Granite.settings.default_timezone) end {% for type, method in NUMERIC_TYPES %} # Converts a `String` to `{{type}}`. def convert_type(value : String, t : {{type.id}}.class) : {{type.id}} value{{method.id}} end # Converts a `String` to `{{type}}?`. def convert_type(value : String, t : {{type.id}}?.class) : {{type.id}}? value{{method.id}} end {% end %} def convert_type(value, type) value end def convert_type(value, type : Bool?.class) : Bool ["1", "yes", "true", true, 1].includes?(value) end end ================================================ FILE: src/granite/validation_helpers/blank.cr ================================================ module Granite::ValidationHelpers macro validate_not_blank(field) validate {{field}}, "#{{{field}}} must not be blank", Proc(self, Bool).new { |model| !model.{{field.id}}.to_s.blank? } end macro validate_is_blank(field) validate {{field}}, "#{{{field}}} must be blank", Proc(self, Bool).new { |model| model.{{field.id}}.to_s.blank? } end end ================================================ FILE: src/granite/validation_helpers/choice.cr ================================================ module Granite::ValidationHelpers macro validate_is_valid_choice(field, choices) validate {{field}}, "#{{{field}}} has an invalid choice. Valid choices are: #{{{choices.join(',')}}}", Proc(self, Bool).new { |model| {{choices}}.includes? model.{{field.id}} } end end ================================================ FILE: src/granite/validation_helpers/exclusion.cr ================================================ module Granite::ValidationHelpers macro validate_exclusion(field, excluded_values) validate {{field}}, "#{{{field.capitalize}}} got reserved values. Reserved values are #{{{excluded_values.join(',')}}}", Proc(self, Bool).new { |model| !{{excluded_values}}.includes? model.{{field.id}}} end end ================================================ FILE: src/granite/validation_helpers/inequality.cr ================================================ module Granite::ValidationHelpers macro validate_greater_than(field, amount, or_equal_to = false) validate {{field}}, "#{{{field}}} must be greater than#{{{or_equal_to}} ? " or equal to" : ""} #{{{amount}}}", Proc(self, Bool).new { |model| (model.{{field.id}}.not_nil! {% if or_equal_to %} >= {% else %} > {% end %} {{amount.id}}) } end macro validate_less_than(field, amount, or_equal_to = false) validate {{field}}, "#{{{field}}} must be less than#{{{or_equal_to}} ? " or equal to" : ""} #{{{amount}}}", Proc(self, Bool).new { |model| (model.{{field.id}}.not_nil! {% if or_equal_to %} <= {% else %} < {% end %} {{amount.id}}) } end end ================================================ FILE: src/granite/validation_helpers/length.cr ================================================ module Granite::ValidationHelpers macro validate_min_length(field, length) validate {{field}}, "#{{{field}}} is too short. It must be at least #{{{length}}}", Proc(self, Bool).new { |model| (model.{{field.id}}.not_nil!.size >= {{length.id}}) } end macro validate_max_length(field, length) validate {{field}}, "#{{{field}}} is too long. It must be at most #{{{length}}}", Proc(self, Bool).new { |model| (model.{{field.id}}.not_nil!.size <= {{length.id}}) } end end ================================================ FILE: src/granite/validation_helpers/nil.cr ================================================ module Granite::ValidationHelpers macro validate_not_nil(field) validate {{field}}, "#{{{field}}} must not be nil", Proc(self, Bool).new { |model| !model.{{field.id}}.nil? } end macro validate_is_nil(field) validate {{field}}, "#{{{field}}} must be nil", Proc(self, Bool).new { |model| model.{{field.id}}.nil? } end end ================================================ FILE: src/granite/validation_helpers/uniqueness.cr ================================================ module Granite::ValidationHelpers macro validate_uniqueness(field) validate {{field}}, "#{{{field}}} should be unique", -> (model: self) do return true if model.{{field.id}}.nil? instance = self.find_by({{field.id}}: model.{{field.id}}) !(instance && instance.id != model.id) end end end ================================================ FILE: src/granite/validators.cr ================================================ require "./error" # Analyze validation blocks and procs # # By example: # ``` # validate :name, "can't be blank" do |user| # !user.name.to_s.blank? # end # # validate :name, "can't be blank", ->(user : User) do # !user.name.to_s.blank? # end # # name_required = ->(model : Granite::Base) { !model.name.to_s.blank? } # validate :name, "can't be blank", name_required # ``` module Granite::Validators @[JSON::Field(ignore: true)] @[YAML::Field(ignore: true)] # Returns all errors on the model. getter errors = [] of Error macro included macro inherited @@validators = Array({field: String, message: String, block: Proc(self, Bool)}).new disable_granite_docs? def self.validate(message : String, &block : self -> Bool) self.validate(:base, message, block) end disable_granite_docs? def self.validate(field : (Symbol | String), message : String, &block : self -> Bool) self.validate(field, message, block) end disable_granite_docs? def self.validate(message : String, block : self -> Bool) self.validate(:base, message, block) end disable_granite_docs? def self.validate(field : (Symbol | String), message : String, block : self -> Bool) @@validators << {field: field.to_s, message: message, block: block} end end end # Runs all of `self`'s validators, returning `true` if they all pass, and `false` # otherwise. # # If the validation fails, `#errors` will contain all the errors responsible for # the failing. def valid? # Return false if any `ConversionError` were added # when setting model properties return false if errors.any? ConversionError errors.clear @@validators.each do |validator| unless validator[:block].call(self) errors << Error.new(validator[:field], validator[:message]) end end errors.empty? end end ================================================ FILE: src/granite/version.cr ================================================ module Granite VERSION = {{ `shards version #{__DIR__}`.strip.stringify }} end ================================================ FILE: src/granite.cr ================================================ require "yaml" require "db" require "log" module Granite Log = ::Log.for("granite") TIME_ZONE = "UTC" DATETIME_FORMAT = "%F %X%z" alias ModelArgs = Hash(Symbol | String, Granite::Columns::Type) annotation Relationship; end annotation Column; end annotation Table; end end require "./granite/base"