acts_as_nosql 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: 6557e20a7d3813b7250a1a6b4e346d07e3b8f89b9ad984d66c6632e8e172bf9e
4
+ data.tar.gz: 8ddacccc9e46e5102dc4056fd4a139e6dde8302e15bd108f66ab7b15214880e7
5
+ SHA512:
6
+ metadata.gz: cf9a0fdfba234f852ffd8fc97fe18b709eaff996037da995bccb672696f03d749ac52e4dc2a82306c87f8bb12efd4f7d9f942a43718bb86d309d6b169fd868dd
7
+ data.tar.gz: 39662c4f73d0d4f1468a1e02d95ddb6624a907079a9ae4984b449356e51bc5ebf631c162508946f330035747591131f469249e8c72b5484ef0fc6c569aca3e5f
@@ -0,0 +1,39 @@
1
+
2
+ module ActsAsNosql
3
+ class Attribute
4
+ attr_reader :name, :type, :default, :path, :type_caster
5
+
6
+ def initialize(name, type: nil, default: nil, path: nil)
7
+ @name = name.to_s
8
+ @type = type
9
+ @default = default
10
+ @path = path&.map { |p| p.to_s }
11
+ @type_caster = type ? "ActiveRecord::Type::#{type}".safe_constantize : nil
12
+ end
13
+
14
+ def cast(value)
15
+ type_caster ? type_caster.new.cast(value) : value
16
+ end
17
+
18
+ def read(attr)
19
+ if path
20
+ attr&.dig(*path)
21
+ else
22
+ attr&.dig(name)
23
+ end
24
+ end
25
+
26
+ def write(attr, value)
27
+ if path
28
+ current = attr
29
+ path[0...-1].each do |key|
30
+ current[key] ||= {}
31
+ current = current[key]
32
+ end
33
+ current[path.last] = cast(value)
34
+ else
35
+ attr[name] = cast(value)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,56 @@
1
+
2
+ module ActsAsNosql
3
+ module Attributes
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ after_initialize :_acts_as_nosql_init
8
+ end
9
+
10
+ def _acts_as_nosql_init
11
+ self.class.nosql_attributes.each do |name, attribute|
12
+ public_send("#{name}=", attribute.default.dup) if public_send(name).nil? && !attribute.default.nil?
13
+ end
14
+ end
15
+
16
+ class_methods do
17
+ def nosql_attrs(*names, type: nil, default: nil, path: nil)
18
+ nosql_attr(*names, type: type, default: default, path: path)
19
+ end
20
+
21
+ def nosql_attr(*names, type: nil, default: nil, path: nil)
22
+ attribute = self._acts_as_nosql_options[:field_name]
23
+
24
+ names.each do |name|
25
+ raise "Attribute #{name} already defined" if instance_methods.include?(name.to_sym) || name.to_sym == attribute.to_sym
26
+
27
+ nosql_attributes[name] = ActsAsNosql::Attribute.new(name, type: type, default: default, path: path)
28
+ _acts_as_nosql_define_attribute(nosql_attributes[name])
29
+ end
30
+ end
31
+
32
+ def nosql_attributes
33
+ self._acts_as_nosql_options[:attributes] ||= {}
34
+ end
35
+
36
+ private
37
+
38
+ def nosql_data_attribute
39
+ @nosql_data_attribute ||= self._acts_as_nosql_options[:field_name]
40
+ end
41
+
42
+ def _acts_as_nosql_define_attribute(nosql_attribute)
43
+ attribute = nosql_data_attribute
44
+
45
+ define_method(nosql_attribute.name) do
46
+ nosql_attribute.read(self[attribute])
47
+ end
48
+
49
+ define_method("#{nosql_attribute.name}=") do |value|
50
+ self[attribute] ||= {}
51
+ nosql_attribute.write(self[attribute], value)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,86 @@
1
+
2
+ module ActsAsNosql
3
+ module Querying
4
+ def where(*args)
5
+ args, json_args = *extract_json_args(args)
6
+
7
+ if json_args
8
+ chain = _acts_as_nosql_parse_where(json_args)
9
+
10
+ args.first.empty? ? chain : chain.where(*args)
11
+ else
12
+ super
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def extract_json_args(args)
19
+ return [args, nil] unless args.first.is_a?(Hash)
20
+ return [args, nil] unless model._acts_as_nosql_options
21
+
22
+ attrs = model._acts_as_nosql_options[:attributes]
23
+ hash = args.first
24
+
25
+ return [args, nil] if hash.keys.none? { |key| attrs.key?(key) }
26
+
27
+ json_hash = {}
28
+ nonjson_hash = {}
29
+
30
+ hash.each do |key, value|
31
+ if attrs.key?(key)
32
+ json_hash[key] = value
33
+ else
34
+ nonjson_hash[key] = value
35
+ end
36
+ end
37
+
38
+ [
39
+ [nonjson_hash, args[1..-1]],
40
+ json_hash
41
+ ]
42
+ end
43
+
44
+ def _acts_as_nosql_parse_where(hash)
45
+ hash.inject(self) do |chain, (key, value)|
46
+ attr = model._acts_as_nosql_options[:attributes][key]
47
+ chain.where(*build_sql_query(attr, value))
48
+ end
49
+ end
50
+
51
+ def build_sql_query(attribute, value)
52
+ field_name = model._acts_as_nosql_options[:field_name]
53
+ ["#{quote_chain(attribute, field_name)} = ?", value]
54
+ end
55
+
56
+ def quote_chain(attribute, field_name)
57
+ if connection.adapter_name == 'PostgreSQL'
58
+ quote_chain_psql(attribute, field_name)
59
+ else
60
+ quote_chain_base(attribute, field_name)
61
+ end
62
+ end
63
+
64
+ def quote_chain_base(attribute, field_name)
65
+ base = quote_full_column(field_name)
66
+ path = attribute.path || [attribute.name]
67
+ "#{base}->>'$.#{path.join('.')}'"
68
+ end
69
+
70
+ def quote_chain_psql(attribute, field_name)
71
+ base = quote_full_column(field_name)
72
+ path = attribute.path
73
+ if attribute.path
74
+ "#{base}->#{path[0...-1].map { |e| "'#{e}'"}.join('->')}->>'#{path.last}'"
75
+ else
76
+ "#{base}->>'#{attribute.name}'"
77
+ end
78
+ end
79
+
80
+ def quote_full_column(field_name)
81
+ connection.quote_table_name(arel_table.table_alias || arel_table.table_name) +
82
+ '.' +
83
+ connection.quote_column_name(field_name)
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,3 @@
1
+ module ActsAsNosql
2
+ VERSION = '0.1.0'.freeze
3
+ end
@@ -0,0 +1,27 @@
1
+ require 'active_record'
2
+
3
+ module ActsAsNosql
4
+ extend ActiveSupport::Concern
5
+ extend ActiveSupport::Autoload
6
+
7
+ class_methods do
8
+ attr_accessor :_acts_as_nosql_options
9
+ # cattr_accessor :_acts_as_nosql_options
10
+ def acts_as_nosql(field_name: nil)
11
+ @_acts_as_nosql_options = { field_name: field_name }
12
+
13
+ include ActsAsNosql::Attributes
14
+ end
15
+
16
+ def _acts_as_nosql_options
17
+ @_acts_as_nosql_options
18
+ end
19
+ end
20
+
21
+ autoload :Attribute
22
+ autoload :Attributes
23
+ autoload :Querying
24
+ end
25
+
26
+ ActiveRecord::Base.include ActsAsNosql
27
+ ActiveRecord::Relation.prepend(ActsAsNosql::Querying)
@@ -0,0 +1,50 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'Fields declaration' do
4
+
5
+ subject { Article.new }
6
+
7
+ it 'has declared fields' do
8
+ expect(subject).to respond_to(:body)
9
+ expect(subject).to respond_to(:body=)
10
+ expect(subject).to respond_to(:comments_count)
11
+ expect(subject).to respond_to(:comments_count=)
12
+ expect(subject).to respond_to(:published)
13
+ expect(subject).to respond_to(:published=)
14
+ end
15
+
16
+ it 'uses default values' do
17
+ expect(subject.comments_count).to eq(0)
18
+ expect(subject.published).to eq(false)
19
+ expect(subject.body).to eq(nil)
20
+ end
21
+
22
+ it 'casts fields' do
23
+ subject.body = 2
24
+ expect(subject.body).to eq('2')
25
+
26
+ subject.published = '1'
27
+ expect(subject.published).to eq(true)
28
+
29
+ subject.comments_count = '1'
30
+ expect(subject.comments_count).to eq(1)
31
+ end
32
+
33
+ it 'raises error if there\'s a name conflict with the field_name' do
34
+ expect { Article.nosql_attr :data }.to raise_error("Attribute data already defined")
35
+ end
36
+
37
+ it 'raises error if there\'s a name conflict' do
38
+ expect { Article.nosql_attr :some_column }.to raise_error("Attribute some_column already defined")
39
+ end
40
+
41
+ it 'fileds are saved as string' do
42
+ subject.editor = 'John Doe'
43
+ subject.save!
44
+ subject.reload
45
+ expect(subject.editor).to eq('John Doe')
46
+ expect(subject.data['editor']).to eq('John Doe')
47
+ expect(subject.data[:editor]).to eq(nil)
48
+ expect(subject.data.keys.first).to be_a(String)
49
+ end
50
+ end
@@ -0,0 +1,53 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'Nested fields declaration' do
4
+
5
+ subject { Setting.new }
6
+
7
+ it 'has declared fields' do
8
+ expect(subject).to respond_to(:user_auth_token)
9
+ expect(subject).to respond_to(:user_auth_token=)
10
+ end
11
+
12
+ it 'uses default values' do
13
+ expect(subject.user_auth_token).to eq('')
14
+ end
15
+
16
+ it 'casts fields' do
17
+ subject.user_auth_token = 2
18
+ expect(subject.user_auth_token).to eq('2')
19
+ end
20
+
21
+ it 'writes nested values' do
22
+ subject.user_auth_token = 2
23
+ expect(subject.config).to eq({ 'user' => { 'auth' => { 'providers' => [], 'token' => '2' } } })
24
+ end
25
+
26
+ it 'data is persisted' do
27
+ subject.user_auth_token = 2
28
+ subject.save!
29
+ subject.reload
30
+ expect(subject.user_auth_token).to eq('2')
31
+ expect(subject.config).to eq({ 'user' => { 'auth' => { 'providers' => [], 'token' => '2' } } })
32
+ end
33
+
34
+ context 'with array type' do
35
+ it 'handles writes' do
36
+ expect(subject.user_auth_providers).to eq([])
37
+ subject.user_auth_providers = ['google']
38
+ expect(subject.user_auth_providers).to eq(['google'])
39
+ subject.save!
40
+ subject.reload
41
+ expect(subject.config).to eq({ 'user' => { 'auth' => { 'providers' => ['google'], 'token' => '' } } })
42
+ end
43
+
44
+ it 'handles mutation' do
45
+ expect(subject.user_auth_providers).to eq([])
46
+ subject.user_auth_providers << 'google'
47
+ expect(subject.user_auth_providers).to eq(['google'])
48
+ subject.save!
49
+ subject.reload
50
+ expect(subject.config).to eq({ 'user' => { 'auth' => { 'providers' => ['google'], 'token' => '' } } })
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,45 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'Querying' do
4
+ context 'simple attributes' do
5
+ let!(:article) do
6
+ Article.create!(
7
+ body: 'body',
8
+ published: true,
9
+ comments_count: 5,
10
+ )
11
+ end
12
+
13
+ let!(:article2) do
14
+ Article.create!
15
+ end
16
+
17
+ it 'can be queried' do
18
+ query = Article.where(body: 'body')
19
+ # expect(query.to_sql).to eq(
20
+ # "SELECT \"articles\".* FROM \"articles\" WHERE (\"articles\".\"data\"->>\"body\" = 'body')"
21
+ # )
22
+ expect(query.to_a).to contain_exactly(article)
23
+ end
24
+ end
25
+
26
+ context 'nested attributes' do
27
+ let!(:setting) do
28
+ Setting.create!(
29
+ user_auth_token: '123123',
30
+ )
31
+ end
32
+
33
+ let!(:setting2) do
34
+ Setting.create!
35
+ end
36
+
37
+ it 'can query nested attributes' do
38
+ query = Setting.where(user_auth_token: '123123')
39
+ # expect(query.to_sql).to eq(
40
+ # "SELECT \"settings\".* FROM \"settings\" WHERE (\"settings\".\"config\"->>\"user\"->>\"auth\"->>\"token\" = '123123')"
41
+ # )
42
+ expect(query.to_a).to contain_exactly(setting)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,22 @@
1
+ require 'active_support'
2
+ require 'rspec'
3
+ require 'acts_as_nosql'
4
+ require 'active_record'
5
+
6
+ I18n.enforce_available_locales = false
7
+ RSpec::Expectations.configuration.warn_about_potential_false_positives = false
8
+
9
+ Dir[File.expand_path('support/*.rb', __dir__)].each { |f| require f }
10
+
11
+ RSpec.configure do |config|
12
+ config.before(:suite) do
13
+ Schema.create
14
+ end
15
+
16
+ config.around(:each) do |example|
17
+ ActiveRecord::Base.transaction do
18
+ example.run
19
+ raise ActiveRecord::Rollback
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,64 @@
1
+ require 'active_record'
2
+
3
+ if ENV['ACTIVE_RECORD_ADAPTER'] == 'mysql'
4
+ puts 'Running on MySQL...'
5
+ ActiveRecord::Base.establish_connection(
6
+ adapter: 'mysql2',
7
+ username: ENV['DB_USERNAME'] || 'root',
8
+ password: ENV['DB_PASSWORD'],
9
+ database: 'acts_as_nosql'
10
+ )
11
+ elsif ENV['ACTIVE_RECORD_ADAPTER'] == 'postgresql'
12
+ puts 'Running on PostgreSQL...'
13
+ ActiveRecord::Base.establish_connection(
14
+ adapter: 'postgresql',
15
+ database: 'acts_as_nosql',
16
+ host: ENV['DB_HOST'] || '127.0.0.1',
17
+ username: ENV['DB_USERNAME'] || 'postgres',
18
+ password: ENV['DB_PASSWORD']
19
+ )
20
+ else
21
+ puts 'Running on SQLite...'
22
+ ActiveRecord::Base.establish_connection(
23
+ adapter: 'sqlite3',
24
+ database: ':memory:'
25
+ )
26
+ end
27
+
28
+ class Article < ActiveRecord::Base
29
+ acts_as_nosql field_name: :data
30
+
31
+ def some_column
32
+ end
33
+
34
+ nosql_attrs :body, :editor, type: String
35
+ nosql_attr :comments_count, type: :Integer, default: 0
36
+ nosql_attr :published, type: :Boolean, default: false
37
+ end
38
+
39
+ class Setting < ActiveRecord::Base
40
+ acts_as_nosql field_name: :config
41
+
42
+ nosql_attrs :user_auth_token, type: String, default: '', path: [:user, :auth, :token]
43
+ nosql_attrs :user_auth_providers, type: Array, default: [], path: [:user, :auth, :providers]
44
+ end
45
+
46
+ module Schema
47
+ def self.create
48
+ ActiveRecord::Migration.verbose = false
49
+
50
+ ActiveRecord::Schema.define do
51
+ create_table :articles, force: true do |t|
52
+ t.string :title
53
+ t.json :data
54
+ t.timestamps null: false
55
+ end
56
+
57
+ create_table :settings, force: true do |t|
58
+ t.string :title
59
+ t.json :config
60
+ t.timestamps null: false
61
+ end
62
+ end
63
+ end
64
+ end
metadata ADDED
@@ -0,0 +1,146 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: acts_as_nosql
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Mònade
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2022-06-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: actionpack
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '5'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '8'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '5'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '8'
33
+ - !ruby/object:Gem::Dependency
34
+ name: activesupport
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '5'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '8'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '5'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '8'
53
+ - !ruby/object:Gem::Dependency
54
+ name: activerecord
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '5'
60
+ - - "<"
61
+ - !ruby/object:Gem::Version
62
+ version: '8'
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '5'
70
+ - - "<"
71
+ - !ruby/object:Gem::Version
72
+ version: '8'
73
+ - !ruby/object:Gem::Dependency
74
+ name: rspec
75
+ requirement: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - "~>"
78
+ - !ruby/object:Gem::Version
79
+ version: '3'
80
+ type: :development
81
+ prerelease: false
82
+ version_requirements: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - "~>"
85
+ - !ruby/object:Gem::Version
86
+ version: '3'
87
+ - !ruby/object:Gem::Dependency
88
+ name: rubocop
89
+ requirement: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ type: :development
95
+ prerelease: false
96
+ version_requirements: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ description: It allows to handle JSON and JSONB fields as if they are proper database
102
+ columns, handling default values, type casting and easier validation.
103
+ email: team@monade.io
104
+ executables: []
105
+ extensions: []
106
+ extra_rdoc_files: []
107
+ files:
108
+ - lib/acts_as_nosql.rb
109
+ - lib/acts_as_nosql/attribute.rb
110
+ - lib/acts_as_nosql/attributes.rb
111
+ - lib/acts_as_nosql/querying.rb
112
+ - lib/acts_as_nosql/version.rb
113
+ - spec/acts_as_nosql/model_spec.rb
114
+ - spec/acts_as_nosql/nested_spec.rb
115
+ - spec/acts_as_nosql/querying_spec.rb
116
+ - spec/spec_helper.rb
117
+ - spec/support/schema.rb
118
+ homepage: https://rubygems.org/gems/acts_as_nosql
119
+ licenses:
120
+ - MIT
121
+ metadata: {}
122
+ post_install_message:
123
+ rdoc_options: []
124
+ require_paths:
125
+ - lib
126
+ required_ruby_version: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: 2.7.0
131
+ required_rubygems_version: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - ">="
134
+ - !ruby/object:Gem::Version
135
+ version: '0'
136
+ requirements: []
137
+ rubygems_version: 3.2.33
138
+ signing_key:
139
+ specification_version: 4
140
+ summary: Use JSON columns as real activerecord attributes
141
+ test_files:
142
+ - spec/acts_as_nosql/model_spec.rb
143
+ - spec/acts_as_nosql/nested_spec.rb
144
+ - spec/acts_as_nosql/querying_spec.rb
145
+ - spec/spec_helper.rb
146
+ - spec/support/schema.rb