cell 0.1.1 → 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 85f49de6468edae5eac2de24fde919be5eb2af39
4
- data.tar.gz: d369c389371266a1cbb0d36268ae76608e9c19e8
3
+ metadata.gz: 688c58ded30105abbc900e3b92d524549b860812
4
+ data.tar.gz: e176f9fc293ad7027e53052a09561e26e11f1091
5
5
  SHA512:
6
- metadata.gz: 6f1f93638f278f5797a2c76d5f4d74a9e8c139462417afad7b38d7ca34f51734c6edd047c3afea25f43082ddb6c99f0148b5002f54f1911b021c7aeb9f373d25
7
- data.tar.gz: fc16f2aa775623f1904a11a61edc06a11cbc1d2a4fe3d814f2684f462733d7c64919a9ffe9b841f5b987aa63483b3ef8d7b8ce494f40fdcf2aceb12c011fb4c9
6
+ metadata.gz: ec3f3baee67fa98496dabaf50554f5bcfe4b230d0ae6c5d5eba21d00635e5cfeaed3156e233cce8e31289ae73a012df07090649c93e7d7b23bbc8aa95384901d
7
+ data.tar.gz: 89ef969c710585e9afb2b43ae8ef8eecc343213586e38eaae9a6a463aef45fdf4033f044781243e949fec595821c8ac2f0466c8e00d3af30ef65c182e227ed16
data/.gitignore CHANGED
@@ -3,3 +3,4 @@ log/*.log
3
3
  pkg/
4
4
  test/dummy/log/*.log
5
5
  test/dummy/tmp/
6
+ Gemfile.lock
data/README.md CHANGED
@@ -1,42 +1,38 @@
1
1
  # Cell
2
2
 
3
- Cell provides tenancy and customer isolation for Rails. It tries to be a
4
- simpler and more thorough implementation of things like Apartment.
3
+ Cell provides tenancy and customer isolation for Rails. It tries to be a simpler and more thorough
4
+ implementation of things like Apartment.
5
5
 
6
- The core of Cell was written years ago for Rails 1.2, before Apartment was
7
- available, and has been developed inside an application ever since. We've
8
- aggressively moved it along with the ecosystem, and it still feels more modern
9
- than other implementations we've used. We're in the process of making it a
10
- more generalized gem. Things are a bit messy now.
6
+ The core of Cell was written years ago for Rails 1.2, before Apartment was available, and has been
7
+ developed inside an application ever since. We've aggressively moved it along with the ecosystem,
8
+ and it still feels more modern than other implementations we've used. We're in the process of
9
+ making it a more generalized gem. Things are a bit messy now.
11
10
 
12
- Cell tries to hook into Rails at the highest level available (e.g., ActiveJob
13
- instead of a particular job adapter), except for one place: PostgreSQL. I
14
- think PostgreSQL is the only widely deployed database with the features required
15
- to make a reasonable implementation (namely: schemas, roles, row and column
16
- level security.)
11
+ Cell tries to hook into Rails at the highest level available (e.g., ActiveJob instead of a
12
+ particular job adapter), except for one place: PostgreSQL. I think PostgreSQL is the only widely
13
+ deployed database with the features required to make a reasonable implementation (namely: schemas,
14
+ roles, row and column level security.)
17
15
 
18
- **Cell aggressively uses Ruby 2.3, PostgreSQL 9.6 and Rails 5**. This means
19
- it's probably more appropriate for greenfield projects than bolting on to an
20
- existing application on an older stack.
16
+ **Cell aggressively uses Ruby 2.3, PostgreSQL 9.6 and Rails 5**. This means it's probably more
17
+ appropriate for greenfield projects than bolting on to an existing application on an older stack.
21
18
 
22
- Cell moves forward quickly: Don't expect to be able to hang back on an old
23
- version of Rails and get new features in Cell. We version accordingly not to
24
- break things, but we don't back-port to old Rubies, old PostgreSQLs, or old
25
- Rails versions.
19
+ Cell moves forward quickly: Don't expect to be able to hang back on an old version of Rails and get
20
+ new features in Cell. We version accordingly not to break things, but we don't back-port to old
21
+ Rubies, old PostgreSQLs, or old Rails versions. We'll totally change how it works overnight.
26
22
 
27
- The aforementioned Apartment gem looks like perfectly viable solution for
28
- projects with other constraints.
23
+ The aforementioned Apartment gem looks like perfectly viable solution for projects with other
24
+ constraints.
29
25
 
30
26
  What it handles:
31
27
 
32
- [x] ActiveRecord (including migrations and global tables)
33
- [ ] ActionController (An integration point for you)
34
- [ ] ActiveJob (we're transitioning a Sidekiq adapter to ActiveJob)
35
- [ ] ActionMailer (Your URIs will work)
36
- [ ] ActionView (Caching is automatically keyed on the Cell)
37
- [ ] Redis (optional, via redis-namespace)
38
- [x] Data Directories
39
- [x] Rake (e.g., tasks which run over each Cell)
28
+ - [x] ActiveRecord models
29
+ - [x] Migrations: including shared and global tables
30
+ - [ ] ActionController: An integration point for you
31
+ - [x] ActiveJob: Jobs are executed in the proper context
32
+ - [ ] ActionMailer: Your URIs will work
33
+ - [ ] ActionView: Caching is automatically keyed on the Cell
34
+ - [ ] Redis: optional, via redis-namespace
35
+ - [x] Rake: tasks which run over each Cell
40
36
 
41
37
  The rest of this README will be updated as the code is extracted.
42
38
 
@@ -48,38 +44,118 @@ Add this line to your application's Gemfile:
48
44
  gem 'cell'
49
45
  ```
50
46
 
51
- And then execute:
47
+ ## Tests
52
48
 
53
- $ bundle
49
+ The application Cell was ripped out of had heavy test-coverage of the functionality Cell provides,
50
+ but at the wrong level (e.g., our model, not the Cell behavior which this became). It's proven
51
+ impossible to extract anything workable out of these into this gem, but we're working on starting
52
+ from scratch.
54
53
 
55
- Or install it yourself as:
54
+ # How it Works
56
55
 
57
- $ gem install cell
56
+ The most complicated interaction Cell has with Rails exist with ActiveRecord and its migrations.
57
+ First, what happens in your PostgreSQL database:
58
58
 
59
- ## Tests
59
+ Global tables are generated in the default schema, which we'll just call "public", and they stay
60
+ there. This is what you're used to.
61
+
62
+ Per-tenant tables are redirected to a schema called "cell_prototype".
63
+
64
+ Both of these are versioned by 'public.schema_migrations'. e.g., the prototype and public schemas
65
+ are always considered to be the same version.
66
+
67
+ When you create a new tenant, the `cell_prototype` schema is copied to a new schema for the tenant,
68
+ and the tenant gets its own 'schema_migrations' table, copied from global.
69
+
70
+ When you 'db:migrate', un-ran migrations start populating public and cell_prototype, but not
71
+ tenants.
72
+
73
+ To update existing tenants after a 'db:migrate', you need to run 'cell:db:migrate', which will run
74
+ the migrations needed to bring each tenant up to date. There are an entire suite of `cell:` rake
75
+ tasks that just run the normal task over each tenant. Check out `rake -T | grep cell:`
60
76
 
61
- The application Cell was ripped out of had heavy test-coverage of the
62
- functionality Cell provides, but at the wrong level (e.g., our model, not the
63
- Cell behavior which this became). It's proven impossible to extract anything
64
- workable out of these into this gem, but we're working on starting from
65
- scratch.
77
+ Cell activates a tenant by adjusting 'schema_search_path'. The way to use this is
78
+ `YourModel.use {|block }`. Note that `Cell::Model` ends up aliased to `YourModel`, so
79
+ `Cell::Model.find(...).use` works as well.
66
80
 
81
+ There are a lot of tricks Cell has to use re: thread safety and the query cache to make this act
82
+ normally.
83
+
84
+ In the development mode console, Cell will activate the first tenant for you before you get a REPL.
67
85
 
68
86
  ## Usage
69
87
 
70
- FIXME: Write this!
88
+ First, read the [How it Works](#how-it-works) section. You need to know what's going on in case
89
+ something breaks.
90
+
91
+ Please don't use cell unless you're OK with the idea of digging into the code when things go wrong.
92
+ It's not a drop-in thing yet.
93
+
94
+ Feel free to open PRs or tickets, though, but I'd rather see "We ended up having to use something
95
+ else because Cell doesn't handle this" than "Cell must handle this".
96
+
97
+ I'm now committed to keeping the following section up-to-date, as the only source of piss-poor
98
+ documentation.
99
+
100
+ First, use `cell` in your `Gemfile` as stated earlier.
101
+
102
+ First, you need a migration that does the following, and only the following:
71
103
 
72
- ## Development
104
+ ```ruby
105
+ class InitializeCell < ActiveRecord::Migration[5.0]
106
+ def change
107
+ initialize_cell!
108
+ end
109
+ end
110
+ ```
111
+
112
+ Then you need a migration that creates your tenanted model:
113
+ ```ruby
114
+ class CreateAccounts < ActiveRecord::Migration[5.0]
115
+ def change
116
+ global do
117
+ create_table :accounts do |t|
118
+ # ...
119
+ end
120
+ end
121
+ end
122
+ end
123
+ ```
124
+
125
+ We do the configuration in the model so it can be updated if you're running the (very useful)
126
+ clusterfuck that is Spring: your settings will update in development.
127
+
128
+ ```ruby
129
+ class Account < ApplicationRecord
130
+ extend Cell::Tenant
131
+ end
132
+ ```
133
+
134
+ From there on out, most of the weird shit dealing with Cell will be in migrations. By default, it
135
+ assumes everything you do in a migration is per-tenant. If you want to escape this, or create
136
+ shared/global tables, you need to wrap it in a global block.
137
+
138
+ ```ruby
139
+ class TestMigration < ActiveRecord::Migration[5.0]
140
+ def change
141
+ # This will exist in each tenant
142
+ create_table :users do |t|
143
+ t.string :name, null: false
144
+ end
145
+
146
+ # This is global, and will be shared across all tenants
147
+ global do
148
+ create_table :capabilities do |t|
149
+ t.string :name, null: false
150
+ end
151
+ end
152
+ end
153
+ end
154
+ ```
73
155
 
74
- After checking out the repo, run `bin/setup` to install dependencies.
75
- You can also run `bin/console` for an interactive prompt that will allow you
76
- to experiment.
156
+ `rake db:migrate` updates the global and prototype schema, `rake cell:db:migrate` will run the
157
+ non-global blocks across all existing tenants.
77
158
 
78
- To install this gem onto your local machine, run `bundle exec rake install`. To
79
- release a new version, update the version number in `version.rb`, and then
80
- run `bundle exec rake release`, which will create a git tag for the version,
81
- push git commits and tags, and push the `.gem` file to
82
- [rubygems.org](https://rubygems.org).
83
159
 
84
160
  ## Contributing
85
161
 
data/Rakefile CHANGED
@@ -1,7 +1,8 @@
1
1
  begin
2
2
  require 'bundler/setup'
3
- rescue LoadError
3
+ rescue LoadError => e
4
4
  puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ puts e.backtrace
5
6
  end
6
7
 
7
8
  require 'rdoc/task'
data/cell.gemspec CHANGED
@@ -33,5 +33,5 @@ Gem::Specification.new do |spec|
33
33
 
34
34
  spec.add_development_dependency "minitest", "~> 5.9.0"
35
35
  spec.add_development_dependency "bundler", "~> 1.12"
36
- spec.add_development_dependency "rake", "~> 10.0"
36
+ spec.add_development_dependency "rake", "~> 11.0"
37
37
  end
@@ -0,0 +1,32 @@
1
+ module Cell
2
+ module ActiveJob
3
+ KEY = :'Cell.tenant'
4
+
5
+ def serialize
6
+ if (current_id = ::Cell::Model.current&.cell_id)
7
+ super.merge(KEY => current_id)
8
+ else
9
+ super
10
+ end
11
+ end
12
+
13
+ def deserialize(job_data)
14
+ if job_data.key?(KEY)
15
+ self.cell_tenant = ::Cell::Model.cell_find(job_data[KEY])
16
+ end
17
+ super
18
+ end
19
+
20
+ def self.prepended(cls)
21
+ cls.send(:attr_accessor, :cell_tenant)
22
+ cls.around_perform do |job, block|
23
+ ::Cell::Model.use(job.cell_tenant, &block)
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+
30
+ if defined?(::ActiveJob)
31
+ ::ActiveJob::Base.prepend(::Cell::ActiveJob)
32
+ end
@@ -1,39 +1,41 @@
1
1
  require 'active_record/schema_migration'
2
- require 'cell/with_schema'
2
+ require 'cell/meta'
3
3
 
4
4
  module Cell
5
5
  module CloneSchema
6
- module_function
7
- def clone_schema(con, src, dst)
8
- sql = <<~SQL
9
- SELECT public.clone_schema(#{con.quote(src)}, #{con.quote(dst)}, true);
6
+ def self.connection
7
+ ::ActiveRecord::Base.connection
8
+ end
9
+
10
+
11
+ def self.clone_schema(src, dst)
12
+ connection.execute <<~SQL
13
+ SELECT public.clone_schema(#{connection.quote(src)}, #{connection.quote(dst)}, TRUE)
10
14
  SQL
11
- con.execute(sql)
12
15
  end
13
16
 
14
17
 
15
- def copy_schema_migrations_to(dst, connection:)
16
- Cell::WithSchema.with_schema(dst, exclusive: true,
17
- connection: connection) do
18
+ def self.copy_schema_migrations_to(dst)
19
+ Cell::Meta.with_schema(dst, exclusive: true) do
18
20
  ::ActiveRecord::SchemaMigration.create_table
19
21
  end
20
22
 
21
- con = connection
22
- sql = "INSERT INTO #{con.quote_schema_name(dst)}.schema_migrations " +
23
- "SELECT * from schema_migrations"
24
- con.execute(sql)
23
+ connection.execute <<~SQL
24
+ INSERT INTO #{connection.quote_schema_name(dst)}.schema_migrations
25
+ SELECT * from schema_migrations
26
+ SQL
25
27
  end
26
28
 
27
29
 
28
- def install_function!(con)
30
+ def self.install_function!
29
31
  @created ||= begin
30
- con.execute(function_definition)
32
+ connection.execute(function_definition)
31
33
  true
32
34
  end
33
35
  end
34
36
 
35
37
 
36
- def function_definition
38
+ def self.function_definition
37
39
  File.read(__FILE__).split(/^__END__$/, 2)[-1]
38
40
  end
39
41
  end
data/lib/cell/console.rb CHANGED
@@ -1,25 +1,19 @@
1
1
  module Cell
2
2
  module Console
3
- module_function
4
-
5
- def default_console_tenant
6
- return nil unless Rails.env.development? || (ENV['T'] && ENV['T'] != '')
7
- Rails.application.eager_load!
8
-
9
- if ENV['T']
10
- Cell::Tenant.cell_find(ENV['T']).tap do |r|
11
- fail "Couldn't locate tenant: #{ENV['T']}" if r.nil?
12
- end
13
- elsif Rails.env.development?
14
- Cell::Tenant.first
3
+ def self.default_console_tenant
4
+ if ENV['T'].present?
5
+ Cell::Model.cell_find!(ENV['T'])
6
+ elsif Rails.env.development? && ENV['T'] != ''
7
+ Cell::Model.first
15
8
  end
16
9
  end
17
10
 
18
- def configure!
11
+ def self.configure!
19
12
  if (t = default_console_tenant)
20
- ::Cell::Tenant.use(t)
13
+ ::Cell::Model.set!(t)
21
14
  end
22
15
  end
23
-
24
16
  end
25
17
  end
18
+
19
+ ::Cell::Console.configure!
data/lib/cell/context.rb CHANGED
@@ -1,46 +1,65 @@
1
- # TenantContextManager allows switching the "active" tenant
2
- require 'cell/with_schema'
1
+ require 'cell/meta'
2
+ require 'active_support/callbacks'
3
3
 
4
4
  module Cell
5
5
  module Context
6
6
  THREAD_KEY = :'Cell.cell_id'
7
7
 
8
8
  module ClassMethods
9
+ def self.extended(cls)
10
+ cls.include ActiveSupport::Callbacks
11
+ cls.define_callbacks :set_tenant, :use_tenant
12
+ end
13
+
14
+ def after_set(*args, &block)
15
+ set_callback(:set_tenant, :after, *args, &block)
16
+ end
17
+
18
+ def around_use(*args, &block)
19
+ set_callback(:use_tenant, :around, *args, &block)
20
+ end
21
+
9
22
  def current
10
23
  Thread.current[THREAD_KEY]
11
24
  end
12
25
 
13
- def use(tenant, exclusive: false)
14
- unless tenant.nil? || tenant.is_a?(Cell::Tenant)
15
- fail ArgumentError, "Invalid tenant: #{tenant.inspect}"
16
- end
26
+ def set!(tenant, exclusive: false)
27
+ ::Cell::Meta.with_schema(tenant.schema_name, exclusive: exclusive)
28
+ set_current(tenant)
29
+ tenant.run_callbacks :set_tenant
30
+ tenant
31
+ end
17
32
 
18
- connection = Cell::Tenant.connection
33
+ def use(tenant, exclusive: false)
34
+ fail ArgumentError, "block required" unless block_given?
35
+ saved_tenant = current
19
36
 
20
- if block_given?
21
- saved_tenant = current
22
- begin
23
- ::Cell::WithSchema.with_schema(tenant.schema_name,
24
- exclusive: exclusive,
25
- connection: connection) do
26
- set_current(tenant)
27
- yield Cell::Tenant.current
37
+ if tenant
38
+ ::Cell::Meta.with_schema(tenant.schema_name, exclusive: exclusive) do
39
+ set_current(tenant)
40
+ tenant.run_callbacks :use_tenant do
41
+ yield Cell::Model.current
28
42
  end
29
- ensure
30
- set_current(saved_tenant)
31
43
  end
32
44
  else
33
- ::Cell::WithSchema.with_schema(tenant.schema_name,
34
- exclusive: exclusive,
35
- connection: connection)
36
- set_current(tenant)
37
- tenant
45
+ ::Cell::Meta.with_global_schema do
46
+ set_current(nil)
47
+ yield nil
48
+ end
38
49
  end
50
+
51
+ tenant
52
+ ensure
53
+ set_current(saved_tenant)
39
54
  end
40
55
 
41
56
  private
42
- def set_current(value)
43
- Thread.current[THREAD_KEY] = value
57
+ def set_current(tenant)
58
+ unless tenant.nil? || tenant.is_a?(Cell::Model)
59
+ fail ArgumentError, "Invalid tenant: #{tenant.inspect}"
60
+ end
61
+
62
+ Thread.current[THREAD_KEY] = tenant
44
63
  end
45
64
  end
46
65
 
@@ -48,14 +67,16 @@ module Cell
48
67
  self.class.use(self, *args, &block)
49
68
  end
50
69
 
70
+ def set!(tenant)
71
+ Cell::Model.set!(tenant)
72
+ end
73
+
51
74
  def current?
52
75
  self.class.current == self
53
76
  end
54
77
 
55
78
  def self.prepended(cls)
56
- class << cls
57
- prepend ClassMethods
58
- end
79
+ cls.extend(ClassMethods)
59
80
  end
60
81
  end
61
82
  end
data/lib/cell/meta.rb ADDED
@@ -0,0 +1,87 @@
1
+ require 'cell/schema'
2
+ require 'active_record/internal_metadata'
3
+
4
+ module Cell
5
+ module Meta
6
+ GLOBAL_KEY = 'Cell.global'
7
+
8
+ def self.connection
9
+ ::ActiveRecord::Base.connection
10
+ end
11
+
12
+ def self.with_schema(new_path, exclusive: false)
13
+ saved_path = connection.schema_search_path
14
+ active_path = exclusive ? new_path : "#{new_path},#{saved_path}"
15
+
16
+ set_schema_search_path(active_path)
17
+
18
+ if block_given?
19
+ begin
20
+ yield
21
+ ensure
22
+ set_schema_search_path(saved_path)
23
+ end
24
+ end
25
+ end
26
+
27
+ def self.set_schema_search_path(path)
28
+ if path != connection.schema_search_path
29
+ connection.schema_search_path = path
30
+ connection.clear_query_cache
31
+ end
32
+ end
33
+
34
+ def self.global_schema
35
+ @global_schema ||= ActiveRecord::Base.connection.schema_search_path
36
+ end
37
+
38
+ def self.prototype_schema
39
+ 'cell_prototype'
40
+ end
41
+
42
+ def self.structural_schema
43
+ "#{prototype_schema}, #{global_schema}"
44
+ end
45
+
46
+ def self.with_global_schema(&block)
47
+ with_schema(global_schema, exclusive: true, &block)
48
+ end
49
+
50
+ def self.with_prototype_schema(&block)
51
+ with_schema(prototype_schema, exclusive: true, &block)
52
+ end
53
+
54
+ def self.with_structural_schema(&block)
55
+ with_schema(structural_schema, exclusive: true, &block)
56
+ end
57
+
58
+ def self.global_tables
59
+ with_global_schema do
60
+ (::ActiveRecord::InternalMetadata[GLOBAL_KEY] || '').split(',')
61
+ end
62
+ end
63
+
64
+ def self.set_global_tables(tables)
65
+ with_global_schema do
66
+ table_string = tables.map(&:to_s).sort.uniq.join(',')
67
+ ::ActiveRecord::InternalMetadata[GLOBAL_KEY] = table_string
68
+ end
69
+ end
70
+
71
+ def self.add_global_table(name)
72
+ set_global_tables(global_tables + [name.to_s])
73
+ end
74
+
75
+ def self.remove_global_table(name)
76
+ set_global_tables(global_tables - [name.to_s])
77
+ end
78
+
79
+ def self.global_model?(m)
80
+ @model_map ||= global_tables.map do |k|
81
+ [k, true]
82
+ end.to_h
83
+
84
+ @model_map[m.table_name] || false
85
+ end
86
+ end
87
+ end