acts_as_nosql 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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