cell 0.1.1 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
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