iry 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 21ffff3b3300f8b100bbe0af995b5d0736260f8c17ca93c9f6e66b44b9fd3088
4
+ data.tar.gz: 5df2033893b5f5d8e8bdf7c97061f8bacc185620daa9a42b87167e945cf534ae
5
+ SHA512:
6
+ metadata.gz: dbc9d0e8a465a34834007308bce3955ed5248c8a93699f81a346c6472cf153dc3df982217868a0392b510f32a0d3fc7ead3c6e9474bc55a508fd976a03e64be4
7
+ data.tar.gz: 3810fa311f0c09b51dd681eeaa31d6e36eb6a9ee3c131042e43cc525a0231a8df8cbe6f4ae2a55588de95ca3c7e950bf4ef36bc56410214c9adc71f1ceea1ab7
data/.envrc.example ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env bash
2
+
3
+ export PGDATABASE=iry_development
4
+ export PGHOST=localhost
5
+ export PGPORT=5432
6
+ export PGUSER=postgres
data/.yardopts ADDED
@@ -0,0 +1 @@
1
+ --no-private 'lib/**/*.rb' - 'README.md' 'CHANGELOG.md' VERSION LICENSE '.envrc.example' Gemfile 'Gemfile.lock' 'db/schema.pgsql' 'iry.gemspec'
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2023-07-04
4
+
5
+ - Initial release
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Francesco Belladonna
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # Iry
2
+
3
+ Convert constraint errors into Rails model validation errors.
4
+
5
+ ## Usage
6
+
7
+ Given the following database schema:
8
+
9
+ ```sql
10
+ create extension if not exists "pgcrypto";
11
+ create extension if not exists "btree_gist";
12
+
13
+ create table if not exists users (
14
+ id uuid primary key default gen_random_uuid(),
15
+ unique_text text unique not null default gen_random_uuid()::text
16
+ created_at timestamp(6) not null,
17
+ updated_at timestamp(6) not null
18
+ );
19
+ ```
20
+
21
+ The following constraint can be used on the `User` class:
22
+
23
+ ```ruby
24
+ class User < ActiveRecord::Base
25
+ include Iry
26
+
27
+ belongs_to :user, optional: true
28
+
29
+ unique_constraint :unique_text
30
+ end
31
+ ```
32
+
33
+ When saving a new `User` record or updating it, in case constraint exceptions are raised, these will be rescued and
34
+ validation errors will be applied to the record, like in the following example:
35
+
36
+ ```ruby
37
+ user = User.create!(unique_text: "some unique text")
38
+
39
+ fail_user = User.create(unique_text: "some unique text")
40
+
41
+ fail_user.errors.details.fetch(:unique_text) #=> [{error: :taken}]
42
+ ```
43
+
44
+ Multiple constraints of the same or different types can be present on the model, as long as the `:name` is different.
45
+
46
+ The following constraint types are available:
47
+ - [`check_constraint`](#check_constraint)
48
+ - [`exclusion_constraint`](#exclusion_constraint)
49
+ - [`foreign_key_constraint`](#foreign_key_constraint)
50
+ - [`unique_constraint`](#unique_constraint)
51
+
52
+ The class method `.constraints` is also available, that returns all the constraints applied to a model.
53
+
54
+ ## Constraints
55
+
56
+ ### `check_constraint`
57
+
58
+ Catches a specific check constraint violation.
59
+
60
+ - **key** (`Symbol`) which key will have validation errors added to
61
+ - **name** (optional `String`) constraint name in the database, to detect constraint errors. Infferred if omitted
62
+ - **message** (optional `String` or `Symbol`) error message, defaults to `:invalid`
63
+
64
+ ### `exclusion_constraint`
65
+
66
+ Catches a specific exclusion constraint violation.
67
+
68
+ - **key** (`Symbol`) which key will have validation errors added to
69
+ - **name** (optional `String`) constraint name in the database, to detect constraint errors. Infferred if omitted
70
+ - **message** (optional `String` or `Symbol`) error message, defaults to `:taken`
71
+
72
+ ### `foreign_key_constraint`
73
+
74
+ Catches a specific foreign key constraint violation.
75
+
76
+ - **key_or_keys** (`Symbol` or array of `Symbol`) key or keys used to make the foreign key constraint
77
+ - **name** (optional `String`) constraint name in the database, to detect constraint errors. Infferred if omitted
78
+ - **message** (optional `String` or `Symbol`) error message, defaults to `:required`
79
+ - **error_key** (optional `Symbol`) which key will have validation errors added to
80
+
81
+ ### `unique_constraint`
82
+
83
+ Catches a specific foreign key constraint violation.
84
+
85
+ - **key_or_keys** (`Symbol` or array of `Symbol`) key or keys used to make the unique constraint
86
+ - **name** (optional `String`) constraint name in the database, to detect constraint errors. Infferred if omitted
87
+ - **message** (optional `String` or `Symbol`) error message, defaults to `:taken`
88
+ - **error_key** (optional `Symbol`) which key will have validation errors added to
89
+
90
+ ## Limitations
91
+
92
+ - `valid?` will not check for constraints. If calling `valid?` right after a `save` operation, keep in mind `errors`
93
+ are cleared
94
+ - `create!` and `update!` will raise `ActiveRecord::RecordNotSaved` for constraints that are caught by `iry`, instead
95
+ of `ActiveModel::ValidationError`
96
+ - Currently only PostgreSQL is supported, with the `pg` gem, but it's easy to add support for other databases.
97
+
98
+ ## Installation
99
+
100
+ Install the gem and add to the application's Gemfile by executing:
101
+
102
+ $ bundle add iry
103
+
104
+ If bundler is not being used to manage dependencies, install the gem by executing:
105
+
106
+ $ gem install iry
107
+
108
+ ## Development
109
+
110
+ **Requirements:**
111
+ - PostgreSQL with `psql`, `createdb`, `dropdb`
112
+
113
+ After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
114
+
115
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
116
+
117
+ ## Contributing
118
+
119
+ Bug reports and pull requests are welcome on GitHub at https://github.com/Fire-Dragon-DoL/iry.
data/Rakefile ADDED
@@ -0,0 +1,14 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create(:test) do |t|
7
+ t.libs << "test"
8
+ t.libs << "lib"
9
+ t.test_prelude = "require \"test/test_helper\""
10
+ t.warning = false
11
+ t.test_globs = ["test/**/*_test.rb"]
12
+ end
13
+
14
+ task(default: :test)
data/Steepfile ADDED
@@ -0,0 +1,28 @@
1
+ D = Steep::Diagnostic
2
+
3
+ target(:lib) do
4
+ signature("sig")
5
+
6
+ # Directory name
7
+ check("lib")
8
+ # check "Gemfile" # File name
9
+ # check "app/models/**/*.rb" # Glob
10
+ # ignore "lib/templates/*.rb"
11
+
12
+ # library "pathname", "set" # Standard libraries
13
+ # library "strong_json" # Gems
14
+
15
+ # configure_code_diagnostics(D::Ruby.strict) # `strict` diagnostics setting
16
+ # configure_code_diagnostics(D::Ruby.lenient) # `lenient` diagnostics setting
17
+ # configure_code_diagnostics do |hash| # You can setup everything yourself
18
+ # hash[D::Ruby::NoMethod] = :information
19
+ # end
20
+ end
21
+
22
+ # target :test do
23
+ # signature "sig", "sig-private"
24
+ #
25
+ # check "test"
26
+ #
27
+ # # library "pathname", "set" # Standard libraries
28
+ # end
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.0
data/db/schema.pgsql ADDED
@@ -0,0 +1,14 @@
1
+ create extension if not exists "pgcrypto";
2
+ create extension if not exists "btree_gist";
3
+
4
+ create table if not exists users (
5
+ id uuid primary key default gen_random_uuid(),
6
+ unique_text text unique not null default gen_random_uuid()::text check (unique_text != 'invalid'),
7
+ exclude_text text not null default gen_random_uuid()::text,
8
+ user_id uuid references users (id),
9
+ friend_user_id uuid references users (id),
10
+ created_at timestamp(6) not null,
11
+ updated_at timestamp(6) not null,
12
+ -- acts similar to unique constraint
13
+ exclude using gist (exclude_text with =)
14
+ );
@@ -0,0 +1,27 @@
1
+ module Iry
2
+ # Main function to kick-off **Iry** constraint-checking mechanism
3
+ # If interested in adding support for other databases beside Postgres, modify this file.
4
+ # @private
5
+ module Callbacks
6
+ extend self
7
+
8
+ # @param model [Handlers::Model]
9
+ # @yield
10
+ # @return [void]
11
+ def around_save(model)
12
+ yield
13
+ rescue ActiveRecord::StatementInvalid => err
14
+ handler = Handlers::Null
15
+ case
16
+ when Handlers::PG.handle?(err)
17
+ handler = Handlers::PG
18
+ end
19
+
20
+ is_handled = handler.handle(err, model)
21
+
22
+ if !is_handled
23
+ raise
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,39 @@
1
+ module Iry
2
+ module Constraint
3
+ class Check
4
+ # Infers the check constraint name based on key and table name
5
+ # @param key [Symbol]
6
+ # @param table_name [String]
7
+ # @return [String]
8
+ def self.infer_name(key, table_name)
9
+ "#{table_name}_#{key}_check"
10
+ end
11
+
12
+ # @return [Symbol]
13
+ attr_accessor :key
14
+ # @return [Symbol, String]
15
+ attr_accessor :message
16
+ # @return [String]
17
+ attr_accessor :name
18
+
19
+ # @param key [Symbol] key to apply error message for check constraint to
20
+ # @param message [Symbol, String] the validation error message
21
+ # @param name [String] constraint name
22
+ def initialize(
23
+ key,
24
+ name:,
25
+ message: :invalid
26
+ )
27
+ @key = key
28
+ @message = message
29
+ @name = name
30
+ end
31
+
32
+ # @param model [Handlers::Model]
33
+ # @return [void]
34
+ def apply(model)
35
+ model.errors.add(key, message)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,39 @@
1
+ module Iry
2
+ module Constraint
3
+ class Exclusion
4
+ # Infers the exclusion constraint name based on key and table name
5
+ # @param key [Symbol]
6
+ # @param table_name [String]
7
+ # @return [String]
8
+ def self.infer_name(key, table_name)
9
+ "#{table_name}_#{key}_excl"
10
+ end
11
+
12
+ # @return [Symbol]
13
+ attr_accessor :key
14
+ # @return [Symbol, String]
15
+ attr_accessor :message
16
+ # @return [String]
17
+ attr_accessor :name
18
+
19
+ # @param key [Symbol] key to apply error message for exclusion constraint to
20
+ # @param message [Symbol, String] the validation error message
21
+ # @param name [String] constraint name
22
+ def initialize(
23
+ key,
24
+ name:,
25
+ message: :taken
26
+ )
27
+ @key = key
28
+ @message = message
29
+ @name = name
30
+ end
31
+
32
+ # @param model [Handlers::Model]
33
+ # @return [void]
34
+ def apply(model)
35
+ model.errors.add(key, message)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,44 @@
1
+ module Iry
2
+ module Constraint
3
+ class ForeignKey
4
+ # Infers the unique constraint name based on keys and table name
5
+ # @param keys [<Symbol>]
6
+ # @param table_name [String]
7
+ # @return [String]
8
+ def self.infer_name(keys, table_name)
9
+ "#{table_name}_#{keys.join("_")}_fkey"
10
+ end
11
+
12
+ # @return [<Symbol>]
13
+ attr_accessor :keys
14
+ # @return [Symbol, String]
15
+ attr_accessor :message
16
+ # @return [String]
17
+ attr_accessor :name
18
+ # @return [Symbol]
19
+ attr_accessor :error_key
20
+
21
+ # @param keys [<Symbol>] array of keys to track the uniqueness constraint of
22
+ # @param message [Symbol, String] the validation error message
23
+ # @param name [String] constraint name
24
+ # @param error_key [Symbol] key to which the validation error will be applied to
25
+ def initialize(
26
+ keys,
27
+ name:,
28
+ error_key:,
29
+ message: :required
30
+ )
31
+ @keys = keys
32
+ @message = message
33
+ @name = name
34
+ @error_key = error_key
35
+ end
36
+
37
+ # @param model [Handlers::Model]
38
+ # @return [void]
39
+ def apply(model)
40
+ model.errors.add(error_key, message)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,44 @@
1
+ module Iry
2
+ module Constraint
3
+ class Unique
4
+ # Infers the unique constraint name based on keys and table name
5
+ # @param keys [<Symbol>]
6
+ # @param table_name [String]
7
+ # @return [String]
8
+ def self.infer_name(keys, table_name)
9
+ "#{table_name}_#{keys.join("_")}_key"
10
+ end
11
+
12
+ # @return [<Symbol>]
13
+ attr_accessor :keys
14
+ # @return [Symbol, String]
15
+ attr_accessor :message
16
+ # @return [String]
17
+ attr_accessor :name
18
+ # @return [Symbol]
19
+ attr_accessor :error_key
20
+
21
+ # @param keys [<Symbol>] array of keys to track the uniqueness constraint of
22
+ # @param message [Symbol, String] the validation error message
23
+ # @param name [String] constraint name
24
+ # @param error_key [Symbol] key to which the validation error will be applied to
25
+ def initialize(
26
+ keys,
27
+ name:,
28
+ error_key:,
29
+ message: :taken
30
+ )
31
+ @keys = keys
32
+ @message = message
33
+ @name = name
34
+ @error_key = error_key
35
+ end
36
+
37
+ # @param model [Handlers::Model]
38
+ # @return [void]
39
+ def apply(model)
40
+ model.errors.add(error_key, message)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,26 @@
1
+ module Iry
2
+ # Interface representing a constraint.
3
+ # A constraint has a name and can apply errors to an object inheriting from {ActiveRecord::Base}
4
+ # @abstract
5
+ module Constraint
6
+ # Sets validation errors on the model
7
+ # @abstract
8
+ # @param model [Handlers::Model]
9
+ # @return [void]
10
+ def apply(model)
11
+ end
12
+
13
+ # Name of the constraint to be caught from the database
14
+ # @abstract
15
+ # @return [String]
16
+ def name
17
+ end
18
+
19
+ # Message to be attached as validation error to the model
20
+ # (see Handlers::Model)
21
+ # @abstract
22
+ # @return [Symbol, String]
23
+ def message
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,24 @@
1
+ module Iry
2
+ module Handlers
3
+ # Catch-all handler for unrecognized database adapters
4
+ # @private
5
+ module Null
6
+ extend self
7
+
8
+ # Returns always true, catching any unhandled database exception
9
+ # @param err [StandardError, ActiveRecord::StatementInvalid]
10
+ # @return [Boolean]
11
+ def handle?(err)
12
+ return true
13
+ end
14
+
15
+ # Return always false, failing to handle any constraint
16
+ # @param err [ActiveRecord::StatementInvalid]
17
+ # @param model [Model] should inherit {ActiveRecord::Base} and`include Iry` to match the interface
18
+ # @return [Boolean]
19
+ def handle(err, model)
20
+ return false
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,56 @@
1
+ module Iry
2
+ module Handlers
3
+ # PostgreSQL handler through `pg` gem
4
+ # @private
5
+ module PG
6
+ extend self
7
+
8
+ # @return [Regexp]
9
+ REGEX = %r{
10
+ (?:
11
+ unique\sconstraint|
12
+ check\sconstraint|
13
+ exclusion\sconstraint|
14
+ foreign\skey\sconstraint
15
+ )
16
+ \s"(.+)"
17
+ }x
18
+
19
+ # When true, the handler is able to handle this exception, representing a constraint error in PostgreSQL.
20
+ # This method must ensure not to raise exception in case the postgresql adapter is missing and as such, the
21
+ # postgres constant is undefined
22
+ # @param err [ActiveRecord::StatementInvalid]
23
+ # @return [Boolean]
24
+ def handle?(err)
25
+ return false if !Object.const_defined?("::PG::Error")
26
+ return false if !err.cause.is_a?(::PG::Error)
27
+
28
+ return true if err.cause.is_a?(::PG::UniqueViolation)
29
+ return true if err.cause.is_a?(::PG::CheckViolation)
30
+ return true if err.cause.is_a?(::PG::ExclusionViolation)
31
+ return true if err.cause.is_a?(::PG::ForeignKeyViolation)
32
+
33
+ return false
34
+ end
35
+
36
+ # Appends constraint errors as model errors
37
+ # @param err [ActiveRecord::StatementInvalid]
38
+ # @param model [Model] should inherit {ActiveRecord::Base} and`include Iry` to match the interface
39
+ # @return [void]
40
+ def handle(err, model)
41
+ pgerr = err.cause
42
+ constraint_name_msg = pgerr.result.error_field(::PG::Constants::PG_DIAG_MESSAGE_PRIMARY)
43
+ match = REGEX.match(constraint_name_msg)
44
+ constraint_name = match[1]
45
+ constraint = model.class.constraints[constraint_name]
46
+ if constraint.nil?
47
+ return false
48
+ end
49
+
50
+ constraint.apply(model)
51
+
52
+ return true
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,48 @@
1
+ module Iry
2
+ module Handlers
3
+ # Interface for handlers of different database types
4
+ # @abstract
5
+ module Handler
6
+ # @abstract
7
+ # @param err [ActiveRecord::StatementInvalid] possible constraint error to handle
8
+ # @return [Boolean] true if this database handler is the correct one for this exception
9
+ def handle?(err)
10
+ end
11
+
12
+ # @abstract
13
+ # @param err [ActiveRecord::StatementInvalid] possible constraint error to handle
14
+ # @param model [Model]
15
+ # @return [Boolean] true if this database handler handled the constraint error
16
+ def handle(err, model)
17
+ end
18
+ end
19
+
20
+ # Interface of the model class. This class is usually inherits from {ActiveRecord::Base}
21
+ # @abstract
22
+ module ModelClass
23
+ # @abstract
24
+ # @return [String]
25
+ def table_name
26
+ end
27
+
28
+ # @abstract
29
+ # @return [{String => Constraint}]
30
+ def constraints
31
+ end
32
+ end
33
+
34
+ # Interface of the model that should be used to handle constraints.
35
+ # This object is an instance of {ActiveRecord::Base}
36
+ # @abstract
37
+ module Model
38
+ # @abstract
39
+ # @return [ActiveModel::Errors]
40
+ def errors
41
+ end
42
+
43
+ # @!method class
44
+ # @abstract
45
+ # @return [ModelClass]
46
+ end
47
+ end
48
+ end