cell 0.1.0 → 0.1.1

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: 43577669f0896218fdfe8736d9ff54e35babd8fb
4
- data.tar.gz: 6d43c4a530cbb084b5463e4a6b7e44b91e9a35e8
3
+ metadata.gz: 85f49de6468edae5eac2de24fde919be5eb2af39
4
+ data.tar.gz: d369c389371266a1cbb0d36268ae76608e9c19e8
5
5
  SHA512:
6
- metadata.gz: 6efa21a6ff9fb27cc92d920dbb7e8a6f688286fc5cbff29cf5a5bb0e4d8460908ff15becc8de4f758010fd3331b89cad946b8dc7a1752cd8524c9a4acdc4e88c
7
- data.tar.gz: 826e2b22a7aed96372b3d855e1b67c08ca98777733d9e7cfd4381b475aba62b41aecf08f2daec4f3a625529dc97262a4934e90b7fbadc6a7892f1809fb2ca5fe
6
+ metadata.gz: 6f1f93638f278f5797a2c76d5f4d74a9e8c139462417afad7b38d7ca34f51734c6edd047c3afea25f43082ddb6c99f0148b5002f54f1911b021c7aeb9f373d25
7
+ data.tar.gz: fc16f2aa775623f1904a11a61edc06a11cbc1d2a4fe3d814f2684f462733d7c64919a9ffe9b841f5b987aa63483b3ef8d7b8ce494f40fdcf2aceb12c011fb4c9
data/.gitignore CHANGED
@@ -1,9 +1,5 @@
1
- /.bundle/
2
- /.yardoc
3
- /Gemfile.lock
4
- /_yardoc/
5
- /coverage/
6
- /doc/
7
- /pkg/
8
- /spec/reports/
9
- /tmp/
1
+ .bundle/
2
+ log/*.log
3
+ pkg/
4
+ test/dummy/log/*.log
5
+ test/dummy/tmp/
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2016 Mike Owens
3
+ Copyright (c) 2016 Mike Owens, meter.md llc
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,8 +1,44 @@
1
1
  # Cell
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/cell`. To experiment with that code, run `bin/console` for an interactive prompt.
4
-
5
- TODO: Delete this and the text above, and describe your gem
3
+ Cell provides tenancy and customer isolation for Rails. It tries to be a
4
+ simpler and more thorough implementation of things like Apartment.
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.
11
+
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.)
17
+
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.
21
+
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.
26
+
27
+ The aforementioned Apartment gem looks like perfectly viable solution for
28
+ projects with other constraints.
29
+
30
+ What it handles:
31
+
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)
40
+
41
+ The rest of this README will be updated as the code is extracted.
6
42
 
7
43
  ## Installation
8
44
 
@@ -20,22 +56,38 @@ Or install it yourself as:
20
56
 
21
57
  $ gem install cell
22
58
 
59
+ ## Tests
60
+
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.
66
+
67
+
23
68
  ## Usage
24
69
 
25
- TODO: Write usage instructions here
70
+ FIXME: Write this!
26
71
 
27
72
  ## Development
28
73
 
29
- 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.
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.
30
77
 
31
- 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 tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
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).
32
83
 
33
84
  ## Contributing
34
85
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/cell.
86
+ Bug reports and pull requests are welcome on GitHub at
87
+ https://github.com/metermd/cell.
36
88
 
37
89
 
38
90
  ## License
39
91
 
40
- The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
41
-
92
+ The gem is available as open source under the terms of the
93
+ [MIT License](http://opensource.org/licenses/MIT).
data/Rakefile CHANGED
@@ -1,2 +1,29 @@
1
- require "bundler/gem_tasks"
2
- task :default => :spec
1
+ begin
2
+ require 'bundler/setup'
3
+ rescue LoadError
4
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
+ end
6
+
7
+ require 'rdoc/task'
8
+
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = 'rdoc'
11
+ rdoc.title = 'Testface'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.md')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+
18
+ require 'bundler/gem_tasks'
19
+ require 'rake/testtask'
20
+
21
+ Rake::TestTask.new(:test) do |t|
22
+ t.libs << 'lib'
23
+ t.libs << 'test'
24
+ t.pattern = 'test/**/*_test.rb'
25
+ t.verbose = false
26
+ end
27
+
28
+
29
+ task default: :test
data/bin/test ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env ruby
2
+ $: << File.expand_path(File.expand_path('../../test', __FILE__))
3
+
4
+ require 'bundler/setup'
5
+ require 'rails/test_unit/minitest_plugin'
6
+
7
+ Rails::TestUnitReporter.executable = 'bin/test'
8
+
9
+ exit Minitest.run(ARGV)
data/cell.gemspec CHANGED
@@ -1,6 +1,5 @@
1
- # coding: utf-8
2
- lib = File.expand_path('../lib', __FILE__)
3
- $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
1
+ $:.push File.expand_path("../lib", __FILE__)
2
+
4
3
  require 'cell/version'
5
4
 
6
5
  Gem::Specification.new do |spec|
@@ -26,6 +25,13 @@ Gem::Specification.new do |spec|
26
25
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
27
26
  spec.require_paths = ["lib"]
28
27
 
28
+ # The parts of the Rails stack we care about
29
+ spec.add_dependency "rails", "~> 5.0.0", ">= 5.0.0.1"
30
+
31
+ # Our hard dependency on PostgreSQL
32
+ spec.add_dependency "pg", "~> 0.18.4"
33
+
34
+ spec.add_development_dependency "minitest", "~> 5.9.0"
29
35
  spec.add_development_dependency "bundler", "~> 1.12"
30
- spec.add_development_dependency "rake", "~> 10.0"
36
+ spec.add_development_dependency "rake", "~> 10.0"
31
37
  end
@@ -0,0 +1,233 @@
1
+ require 'active_record/schema_migration'
2
+ require 'cell/with_schema'
3
+
4
+ module Cell
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);
10
+ SQL
11
+ con.execute(sql)
12
+ end
13
+
14
+
15
+ def copy_schema_migrations_to(dst, connection:)
16
+ Cell::WithSchema.with_schema(dst, exclusive: true,
17
+ connection: connection) do
18
+ ::ActiveRecord::SchemaMigration.create_table
19
+ end
20
+
21
+ con = connection
22
+ sql = "INSERT INTO #{con.quote_schema_name(dst)}.schema_migrations " +
23
+ "SELECT * from schema_migrations"
24
+ con.execute(sql)
25
+ end
26
+
27
+
28
+ def install_function!(con)
29
+ @created ||= begin
30
+ con.execute(function_definition)
31
+ true
32
+ end
33
+ end
34
+
35
+
36
+ def function_definition
37
+ File.read(__FILE__).split(/^__END__$/, 2)[-1]
38
+ end
39
+ end
40
+ end
41
+
42
+ # The following function was written by Melvin Davidson, originally based on
43
+ # the work of Emanuel '3manuek', posted at:
44
+ # https://wiki.postgresql.org/wiki/Clone_schema
45
+ __END__
46
+ -- Function: clone_schema(text, text)
47
+
48
+ -- DROP FUNCTION clone_schema(text, text);
49
+
50
+ CREATE OR REPLACE FUNCTION public.clone_schema(
51
+ source_schema text,
52
+ dest_schema text,
53
+ include_recs boolean)
54
+ RETURNS void AS
55
+ $BODY$
56
+
57
+ -- This function will clone all sequences, tables, data, views & functions from any existing schema to a new one
58
+ -- SAMPLE CALL:
59
+ -- SELECT clone_schema('public', 'new_schema', TRUE);
60
+
61
+ DECLARE
62
+ src_oid oid;
63
+ tbl_oid oid;
64
+ func_oid oid;
65
+ object text;
66
+ buffer text;
67
+ srctbl text;
68
+ default_ text;
69
+ column_ text;
70
+ qry text;
71
+ dest_qry text;
72
+ v_def text;
73
+ seqval bigint;
74
+ sq_last_value bigint;
75
+ sq_max_value bigint;
76
+ sq_start_value bigint;
77
+ sq_increment_by bigint;
78
+ sq_min_value bigint;
79
+ sq_cache_value bigint;
80
+ sq_log_cnt bigint;
81
+ sq_is_called boolean;
82
+ sq_is_cycled boolean;
83
+ sq_cycled char(10);
84
+
85
+ BEGIN
86
+
87
+ -- Check that source_schema exists
88
+ SELECT oid INTO src_oid
89
+ FROM pg_namespace
90
+ WHERE nspname = quote_ident(source_schema);
91
+ IF NOT FOUND
92
+ THEN
93
+ RAISE NOTICE 'source schema % does not exist!', source_schema;
94
+ RETURN ;
95
+ END IF;
96
+
97
+ -- Check that dest_schema does not yet exist
98
+ PERFORM nspname
99
+ FROM pg_namespace
100
+ WHERE nspname = quote_ident(dest_schema);
101
+ IF FOUND
102
+ THEN
103
+ RAISE NOTICE 'dest schema % already exists!', dest_schema;
104
+ RETURN ;
105
+ END IF;
106
+
107
+ EXECUTE 'CREATE SCHEMA ' || quote_ident(dest_schema) ;
108
+
109
+ -- Create sequences
110
+ -- TODO: Find a way to make this sequence's owner is the correct table.
111
+ FOR object IN
112
+ SELECT sequence_name::text
113
+ FROM information_schema.sequences
114
+ WHERE sequence_schema = quote_ident(source_schema)
115
+ LOOP
116
+ EXECUTE 'CREATE SEQUENCE ' || quote_ident(dest_schema) || '.' || quote_ident(object);
117
+ srctbl := quote_ident(source_schema) || '.' || quote_ident(object);
118
+
119
+ EXECUTE 'SELECT last_value, max_value, start_value, increment_by, min_value, cache_value, log_cnt, is_cycled, is_called
120
+ FROM ' || quote_ident(source_schema) || '.' || quote_ident(object) || ';'
121
+ INTO sq_last_value, sq_max_value, sq_start_value, sq_increment_by, sq_min_value, sq_cache_value, sq_log_cnt, sq_is_cycled, sq_is_called ;
122
+
123
+ IF sq_is_cycled
124
+ THEN
125
+ sq_cycled := 'CYCLE';
126
+ ELSE
127
+ sq_cycled := 'NO CYCLE';
128
+ END IF;
129
+
130
+ EXECUTE 'ALTER SEQUENCE ' || quote_ident(dest_schema) || '.' || quote_ident(object)
131
+ || ' INCREMENT BY ' || sq_increment_by
132
+ || ' MINVALUE ' || sq_min_value
133
+ || ' MAXVALUE ' || sq_max_value
134
+ || ' START WITH ' || sq_start_value
135
+ || ' RESTART ' || sq_min_value
136
+ || ' CACHE ' || sq_cache_value
137
+ || sq_cycled || ' ;' ;
138
+
139
+ buffer := quote_ident(dest_schema) || '.' || quote_ident(object);
140
+ IF include_recs
141
+ THEN
142
+ EXECUTE 'SELECT setval( ''' || buffer || ''', ' || sq_last_value || ', ' || sq_is_called || ');' ;
143
+ ELSE
144
+ EXECUTE 'SELECT setval( ''' || buffer || ''', ' || sq_start_value || ', ' || sq_is_called || ');' ;
145
+ END IF;
146
+
147
+ END LOOP;
148
+
149
+ -- Create tables
150
+ FOR object IN
151
+ SELECT TABLE_NAME::text
152
+ FROM information_schema.tables
153
+ WHERE table_schema = quote_ident(source_schema)
154
+ AND table_type = 'BASE TABLE'
155
+
156
+ LOOP
157
+ buffer := quote_ident(dest_schema) || '.' || quote_ident(object);
158
+ EXECUTE 'CREATE TABLE ' || buffer || ' (LIKE ' || quote_ident(source_schema) || '.' || quote_ident(object)
159
+ || ' INCLUDING ALL)';
160
+
161
+ IF include_recs
162
+ THEN
163
+ -- Insert records from source table
164
+ EXECUTE 'INSERT INTO ' || buffer || ' SELECT * FROM ' || quote_ident(source_schema) || '.' || quote_ident(object) || ';';
165
+ END IF;
166
+
167
+ FOR column_, default_ IN
168
+ SELECT column_name::text,
169
+ REPLACE(column_default::text, source_schema, dest_schema)
170
+ FROM information_schema.COLUMNS
171
+ WHERE table_schema = dest_schema
172
+ AND TABLE_NAME = object
173
+ AND column_default LIKE 'nextval(%' || quote_ident(source_schema) || '%::regclass)'
174
+ LOOP
175
+ EXECUTE 'ALTER TABLE ' || buffer || ' ALTER COLUMN ' || column_ || ' SET DEFAULT ' || default_;
176
+ END LOOP;
177
+
178
+ END LOOP;
179
+
180
+ -- add FK constraint
181
+ FOR qry IN
182
+ SELECT 'ALTER TABLE ' || quote_ident(dest_schema) || '.' || quote_ident(rn.relname)
183
+ || ' ADD CONSTRAINT ' || quote_ident(ct.conname) || ' ' || pg_get_constraintdef(ct.oid) || ';'
184
+ FROM pg_constraint ct
185
+ JOIN pg_class rn ON rn.oid = ct.conrelid
186
+ WHERE connamespace = src_oid
187
+ AND rn.relkind = 'r'
188
+ AND ct.contype = 'f'
189
+
190
+ LOOP
191
+ EXECUTE qry;
192
+
193
+ END LOOP;
194
+
195
+
196
+ -- Create views
197
+ FOR object IN
198
+ SELECT table_name::text,
199
+ view_definition
200
+ FROM information_schema.views
201
+ WHERE table_schema = quote_ident(source_schema)
202
+
203
+ LOOP
204
+ buffer := dest_schema || '.' || quote_ident(object);
205
+ SELECT view_definition INTO v_def
206
+ FROM information_schema.views
207
+ WHERE table_schema = quote_ident(source_schema)
208
+ AND table_name = quote_ident(object);
209
+
210
+ EXECUTE 'CREATE OR REPLACE VIEW ' || buffer || ' AS ' || v_def || ';' ;
211
+
212
+ END LOOP;
213
+
214
+ -- Create functions
215
+ FOR func_oid IN
216
+ SELECT oid
217
+ FROM pg_proc
218
+ WHERE pronamespace = src_oid
219
+
220
+ LOOP
221
+ SELECT pg_get_functiondef(func_oid) INTO qry;
222
+ SELECT replace(qry, source_schema, dest_schema) INTO dest_qry;
223
+ EXECUTE dest_qry;
224
+
225
+ END LOOP;
226
+
227
+ RETURN;
228
+
229
+ END;
230
+
231
+ $BODY$
232
+ LANGUAGE plpgsql VOLATILE
233
+ COST 100;
@@ -0,0 +1,70 @@
1
+ require 'cell/context'
2
+ require 'cell/model'
3
+ require 'cell/schema'
4
+
5
+ module Cell
6
+ class Configuration
7
+ attr_accessor :model
8
+ attr_accessor :identifier
9
+ attr_accessor :data_directory
10
+ attr_accessor :sidekiq
11
+
12
+ def initialize(model, &block)
13
+ @model = model
14
+ @identifier = :id
15
+ @data_directory = nil
16
+ @sidekiq = false
17
+
18
+ yield self
19
+ commit!(model)
20
+ end
21
+
22
+ def commit!(model)
23
+ fail "model class not specified" if model.nil? || ! model.is_a?(Class)
24
+
25
+ configure_model!(model)
26
+ configure_data_directory!(model)
27
+ configure_sidekiq!(model)
28
+ end
29
+
30
+ private
31
+
32
+ def configure_model!(model)
33
+ ->(identifier) do
34
+ model.define_singleton_method(:cell_id_column) do
35
+ identifier
36
+ end
37
+ end.call(self.identifier)
38
+
39
+ Cell.const_set(:Tenant, model)
40
+ model.prepend(::Cell::Model)
41
+ model.prepend(::Cell::Schema)
42
+ model.prepend(::Cell::Context)
43
+ end
44
+
45
+ def configure_data_directory!(model)
46
+ if self.data_directory
47
+ ->(data_directory) do
48
+ model.define_singleton_method(:data_directory_root) do
49
+ data_directory
50
+ end
51
+ end.call(self.data_directory.to_s)
52
+ model.prepend(::Cell::DataDirectory)
53
+ end
54
+ end
55
+
56
+ def configure_sidekiq!(model)
57
+ if self.sidekiq
58
+ require 'cell/sidekiq'
59
+ ::Cell::Sidekiq.configure!
60
+ end
61
+ end
62
+ end
63
+ private_constant :Configuration
64
+
65
+ module_function
66
+ def configure(model = nil, &block)
67
+ fail ArgumentError, "model or block required" if model.nil? && !block_given?
68
+ Configuration.new(model || block.binding.receiver, &block)
69
+ end
70
+ end
@@ -0,0 +1,25 @@
1
+ module Cell
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
15
+ end
16
+ end
17
+
18
+ def configure!
19
+ if (t = default_console_tenant)
20
+ ::Cell::Tenant.use(t)
21
+ end
22
+ end
23
+
24
+ end
25
+ end
@@ -0,0 +1,61 @@
1
+ # TenantContextManager allows switching the "active" tenant
2
+ require 'cell/with_schema'
3
+
4
+ module Cell
5
+ module Context
6
+ THREAD_KEY = :'Cell.cell_id'
7
+
8
+ module ClassMethods
9
+ def current
10
+ Thread.current[THREAD_KEY]
11
+ end
12
+
13
+ def use(tenant, exclusive: false)
14
+ unless tenant.nil? || tenant.is_a?(Cell::Tenant)
15
+ fail ArgumentError, "Invalid tenant: #{tenant.inspect}"
16
+ end
17
+
18
+ connection = Cell::Tenant.connection
19
+
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
28
+ end
29
+ ensure
30
+ set_current(saved_tenant)
31
+ end
32
+ else
33
+ ::Cell::WithSchema.with_schema(tenant.schema_name,
34
+ exclusive: exclusive,
35
+ connection: connection)
36
+ set_current(tenant)
37
+ tenant
38
+ end
39
+ end
40
+
41
+ private
42
+ def set_current(value)
43
+ Thread.current[THREAD_KEY] = value
44
+ end
45
+ end
46
+
47
+ def use(*args, &block)
48
+ self.class.use(self, *args, &block)
49
+ end
50
+
51
+ def current?
52
+ self.class.current == self
53
+ end
54
+
55
+ def self.prepended(cls)
56
+ class << cls
57
+ prepend ClassMethods
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,42 @@
1
+ require 'fileutils'
2
+
3
+ module Cell
4
+ module DataDirectory
5
+ module ClassMethods
6
+ def data_directory_root
7
+ fail NotImplementedError
8
+ end
9
+
10
+ def data_directory_for_cell_id(cell_id)
11
+ cell_id = cell_id.to_s.gsub(/[^a-z0-9_]/i, '-')
12
+ File.join(data_directory_root, cell_id)
13
+ end
14
+ end
15
+
16
+ def data_directory
17
+ self.class.data_directory_for_cell_id(cell_id)
18
+ end
19
+
20
+ private
21
+ def create_data_directory!
22
+ FileUtils.mkdir_p(data_directory)
23
+ end
24
+
25
+ def update_data_directory!
26
+ src, dst = cell_id_change_set
27
+ FileUtils.mv(self.class.data_directory_for_cell_id(src), data_directory)
28
+ end
29
+
30
+ def destroy_data_directory!
31
+ FileUtils.rm_rf(data_directory)
32
+ end
33
+
34
+ def self.prepended(cls)
35
+ cls.extend(ClassMethods)
36
+
37
+ cls.after_create_commit :create_data_directory!
38
+ cls.after_update_commit :update_data_directory!, if: :cell_id_changed?
39
+ cls.after_destroy_commit :destroy_data_directory!
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,114 @@
1
+ #
2
+ # Alright, here's how this works:
3
+ #
4
+ # A migration has two execution modes, global and targeted.
5
+
6
+ # "global" is ran with a "db:migrate", which means no Tenant is activated.
7
+ # "targeted" is ran once over each tenant, via "cell:db:migrate".
8
+ #
9
+ # In global mode:
10
+ # Each migration is ran twice: once with no explicit schema set, then again
11
+ # with "tenant_prototype" activated.
12
+
13
+ require 'cell/schema'
14
+ require 'cell/clone_schema'
15
+ require 'cell/with_schema'
16
+
17
+ module Cell
18
+ module Migration
19
+ def self.intercept(methods)
20
+ methods.each do |method|
21
+ define_method(method) do |*args, &block|
22
+ super(*args, &block) if execute_ddl?
23
+ end
24
+ end
25
+ end
26
+
27
+ # This sucks.
28
+ intercept %i(add_belongs_to add_column add_foreign_key add_index
29
+ add_index_sort_order add_reference add_timestamps
30
+ change_column change_column_default change_column_null
31
+ change_table change_table_comment create_join_table
32
+ create_table drop_join_table drop_table
33
+ initialize_schema_migrations_table remove_belongs_to
34
+ remove_column remove_columns remove_foreign_key remove_index
35
+ remove_reference remove_timestamps rename_column
36
+ rename_column_indexes rename_index rename_table
37
+ rename_table_indexes table_alias_for table_comment) +
38
+ %i(enable_extension disable_extension truncate) +
39
+ %i(execute)
40
+
41
+ attr_accessor :pass_context
42
+ attr_accessor :global_block
43
+
44
+ def execute_ddl?
45
+ (pass_context == :global && global_block) ||
46
+ (pass_context == :prototype && !global_block) ||
47
+ (pass_context == :target && !global_block)
48
+ end
49
+
50
+ def global_schema
51
+ Cell::Schema.global_schema
52
+ end
53
+
54
+ def prototype_schema
55
+ Cell::Schema.prototype_schema
56
+ end
57
+
58
+ def tenant_schema
59
+ target.schema_name
60
+ end
61
+
62
+ def target
63
+ Cell::Tenant.current
64
+ end
65
+
66
+ def targeted?
67
+ !! target
68
+ end
69
+
70
+ def with_context(context, search_path)
71
+ Cell::WithSchema::with_schema(search_path, connection: connection) do
72
+ begin
73
+ saved_context = self.pass_context
74
+ self.pass_context = context
75
+ yield
76
+ ensure
77
+ self.pass_context = saved_context
78
+ end
79
+ end
80
+ end
81
+
82
+
83
+ def initialize_cell!
84
+ CloneSchema.install_function!(connection)
85
+ execute "CREATE SCHEMA #{connection.quote_schema_name(prototype_schema)}"
86
+ end
87
+
88
+ def exec_migration(con, direction)
89
+ if ! targeted?
90
+ with_context(:global, global_schema) do
91
+ super
92
+ end
93
+
94
+ with_context(:prototype, prototype_schema) do
95
+ super
96
+ end
97
+ else
98
+ with_context(:target, tenant_schema) do
99
+ super
100
+ end
101
+ end
102
+ end
103
+
104
+ def global
105
+ saved = self.global_block
106
+ self.global_block = true
107
+ yield
108
+ ensure
109
+ self.global_block = saved
110
+ end
111
+
112
+ ActiveRecord::Migration.prepend(self)
113
+ end
114
+ end
data/lib/cell/model.rb ADDED
@@ -0,0 +1,37 @@
1
+ require 'cell/schema'
2
+ require 'cell/data_directory'
3
+
4
+ module Cell
5
+ module Model
6
+ class << self
7
+ attr_accessor :cls
8
+ end
9
+
10
+ module ClassMethods
11
+ def cell_id_column
12
+ fail NotImplementedError
13
+ end
14
+
15
+ def cell_find(cell_id)
16
+ find_by(cell_id_column => cell_id)
17
+ end
18
+ end
19
+
20
+ def cell_id
21
+ send(self.class.cell_id_column)
22
+ end
23
+
24
+ def cell_id_changed?
25
+ !! previous_changes[self.class.cell_id_column]
26
+ end
27
+
28
+ def cell_id_change_set
29
+ fail "cell_id was not changed" unless cell_id_changed?
30
+ previous_changes[self.class.cell_id_column]
31
+ end
32
+
33
+ def self.prepended(cls)
34
+ cls.extend(ClassMethods)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,17 @@
1
+ module Cell
2
+ class Railtie < Rails::Railtie
3
+ rake_tasks do
4
+ load 'cell/tasks/cell.rake'
5
+ end
6
+
7
+ console do
8
+ require 'cell/console'
9
+ Cell::Console.configure!
10
+ end
11
+
12
+ initializer "cell.sanity_check" do
13
+ require 'cell/sanity_check'
14
+ Cell::SanityCheck.sanity_check!
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,66 @@
1
+ require 'active_record/connection_adapters/postgresql_adapter'
2
+
3
+ module Cell
4
+ module SanityCheck
5
+
6
+ module_function
7
+ def check_active_record_adapter!
8
+ pg_base_adapter = ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
9
+ whitelist = ["PostgreSQLAdapter"]
10
+ adapter_name = ActiveRecord::Base.connection.adapter_name
11
+
12
+ unless ActiveRecord::Base.connection.is_a?(pg_base_adapter) ||
13
+ whitelist.include?(adapter_name)
14
+ msg <<~EOD
15
+ Cell uses PostgreSQL-specific features that cannot be represented
16
+ with your adapter. If your adapter is a PostgreSQL spin-off, please
17
+ open a pull request.
18
+
19
+ Whitelist: #{whitelist.inspect}
20
+ ActiveRecord adapter: #{adapter_name}
21
+ EOD
22
+ fail msg
23
+ end
24
+ end
25
+
26
+
27
+ def check_schema_format!
28
+ if Rails.application.config.active_record.schema_format != :sql
29
+ msg = <<~EOD
30
+ Cell uses PostgreSQL-specific features that cannot be represented
31
+ using a schema_format other than :sql. You need a definititive
32
+ structure.sql instead of a schema.rb. You can configure this by
33
+ adding the following line to your application.rb:
34
+
35
+ Rails.application.config.active_record.schema_format = :sql
36
+ EOD
37
+ fail msg
38
+ end
39
+ end
40
+
41
+ def check_dump_schemas!
42
+ dump_schemas = Rails.application.config.active_record.dump_schemas
43
+ unless dump_schemas.to_s.match(/\bcell_prototype\b/)
44
+ msg = <<~EOD
45
+ Cell stores tenant templates in a PostgreSQL schema called
46
+ "cell_prototype".
47
+
48
+ Rails will not dump this schema by default with `db:structure:dump`
49
+ without explicitly setting `dump_schemas`.
50
+
51
+ You can configure this properly by adding a line like the following
52
+ in application.rb:
53
+
54
+ Rails.application.config.active_record.dump_schemas = "public,cell_prototype"
55
+ EOD
56
+ fail msg
57
+ end
58
+ end
59
+
60
+ def sanity_check!
61
+ check_active_record_adapter!
62
+ check_schema_format!
63
+ check_dump_schemas!
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,64 @@
1
+ require 'shellwords'
2
+ require 'cell/clone_schema'
3
+ require 'active_record/schema_migration'
4
+
5
+ module Cell
6
+ module Schema
7
+ SCHEMA_PREFIX = 't_'
8
+ MAX_SCHEMA_NAME_LENGTH = 63 # PostgreSQL baked-in default
9
+ MAX_CELL_ID_SIZE = MAX_SCHEMA_NAME_LENGTH - SCHEMA_PREFIX.size
10
+
11
+ module_function \
12
+ def global_schema
13
+ @global_schema ||= ActiveRecord::Base.connection.schema_search_path
14
+ end
15
+
16
+ module_function \
17
+ def prototype_schema
18
+ 'cell_prototype'
19
+ end
20
+
21
+ module ClassMethods
22
+ def schema_name_for_cell_id(cell_id)
23
+ SCHEMA_PREFIX + cell_id.to_s.gsub(/[^a-z0-9_]/i, '-')
24
+ end
25
+ end
26
+
27
+ def schema_name
28
+ self.class.schema_name_for_cell_id(cell_id)
29
+ end
30
+
31
+ private
32
+ def create_schema!
33
+ con = self.class.connection
34
+ con.transaction do
35
+ Cell::CloneSchema.clone_schema(con, Cell::Schema.prototype_schema,
36
+ schema_name)
37
+ Cell::CloneSchema.copy_schema_migrations_to(schema_name, connection: con)
38
+ end
39
+ end
40
+
41
+ def destroy_schema!
42
+ con = self.class.connection
43
+ con.execute \
44
+ "DROP SCHEMA #{con.quote_schema_name(schema_name)} CASCADE"
45
+ end
46
+
47
+ def update_schema!
48
+ src, dst = cell_id_change_set
49
+ src = self.class.schema_name_for_cell_id(src)
50
+
51
+ con = self.class.connection
52
+ con.execute \
53
+ "ALTER SCHEMA #{con.quote_schema_name(src)} RENAME " +
54
+ "TO #{con.quote_schema_name(schema_name)}"
55
+ end
56
+
57
+ def self.prepended(cls)
58
+ cls.extend(ClassMethods)
59
+ cls.after_create_commit :create_schema!
60
+ cls.after_update_commit :update_schema!, if: :cell_id_changed?
61
+ cls.after_destroy_commit :destroy_schema!
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,58 @@
1
+ # This middleware passes along a tenant_id from client to server if a tenant
2
+ # is active when a job is spun off. The server process then activates the
3
+ # proper tenant if it's detected, so jobs are processed under the context of the
4
+ # same tenant that started the job on the client.
5
+
6
+
7
+ require 'sidekiq'
8
+
9
+ module Cell
10
+ module Sidekiq
11
+ KEY = 'Cell.cell_id'
12
+
13
+ class ClientMiddleware
14
+ def call(worker, msg, queue, redis_pool)
15
+ if (current_id = ::Cell.current&.cell_id)
16
+ msg[KEY] = current_id
17
+ end
18
+
19
+ yield
20
+ true
21
+ end
22
+ end
23
+
24
+ class ServerMiddleware
25
+ def call(worker, msg, queue, &block)
26
+ if msg.key?(KEY)
27
+ ::Cell::Tenant.use(::Cell::Tenant.cell_find(msg[KEY]), &block)
28
+ else
29
+ ::Cell::Tenant.use(nil, &block)
30
+ end
31
+
32
+ true
33
+ end
34
+ end
35
+
36
+ module_function
37
+ def configure!
38
+ ::Sidekiq.configure_client do |config|
39
+ config.client_middleware do |chain|
40
+ chain.add ClientMiddleware
41
+ end
42
+ end
43
+
44
+ ::Sidekiq.configure_server do |config|
45
+ # The server can also act as a client by creating more jobs, so we
46
+ # should configure the client inside the server block.
47
+ config.client_middleware do |chain|
48
+ chain.add ClientMiddleware
49
+ end
50
+
51
+ config.server_middleware do |chain|
52
+ chain.add ServerMiddleware
53
+ end
54
+ end
55
+
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,56 @@
1
+ def db_tasks
2
+ Rake::Task.tasks.select do |t|
3
+ t.name.start_with?('db:')
4
+ end
5
+ end
6
+
7
+
8
+ def wrappable_task?(task)
9
+ fail ArgumentError unless task.name.start_with?('db:')
10
+
11
+ case task.name
12
+ when /^db:test\b/, /^db:structure\b/, /^db:schema\b/, /^db:setup\b/,
13
+ /^db:purge\b/, /^db:reset\b/, /^db:load_config\b/, /^db:create\b/,
14
+ /^db:drop\b/
15
+ false
16
+ else
17
+ true
18
+ end
19
+ end
20
+
21
+
22
+ def using_each_tenant(&block)
23
+ # We have to get the model that included Cell::Tenant
24
+ require Rails.root.join('config/environment')
25
+
26
+ tenants = if ENV['T']
27
+ ENV['T'].split(/\s*,\s*/).map do |tenant_id|
28
+ Cell::Tenant.cell_find(tenant_id).tap do |r|
29
+ fail "Unable to load tenant: #{tenant_id}" if r.nil?
30
+ end
31
+ end
32
+ else
33
+ ::Cell::Tenant.respond_to?(:find_each) ? ::Cell::Tenant.find_each
34
+ : ::Cell::Tenant.each
35
+ end
36
+
37
+ tenants.each do |t|
38
+ Cell::Tenant.use(t, &block)
39
+ end
40
+ end
41
+
42
+
43
+ db_tasks.each do |db_task|
44
+ if wrappable_task?(db_task)
45
+ if db_task.comment
46
+ desc "run `#{db_task.name}' for all tenants, or each in $T=t1,t2,..."
47
+ end
48
+ task "cell:#{db_task.name}" => ['environment', 'db:load_config'] do |t|
49
+ Rails.application.eager_load!
50
+ using_each_tenant do |tenant|
51
+ puts "Running #{db_task.name} for tenant '#{tenant.cell_id}'"
52
+ Rake::Task[db_task.name].execute
53
+ end
54
+ end
55
+ end
56
+ end
data/lib/cell/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Cell
2
- VERSION = "0.1.0"
2
+ VERSION = '0.1.1'
3
3
  end
@@ -0,0 +1,30 @@
1
+ require 'active_record/base'
2
+
3
+ module Cell
4
+ module WithSchema
5
+ module_function
6
+ def with_schema(new_path, exclusive: false, connection:)
7
+ saved_path = connection.schema_search_path
8
+ new_path = connection.quote_schema_name(new_path)
9
+ active_path = exclusive ? new_path : "#{new_path},#{saved_path}"
10
+
11
+ set_schema_search_path(active_path, connection: connection)
12
+
13
+ if block_given?
14
+ begin
15
+ yield
16
+ ensure
17
+ set_schema_search_path(saved_path, connection: connection)
18
+ end
19
+ end
20
+ end
21
+
22
+ private \
23
+ def set_schema_search_path(path, connection:)
24
+ if path != connection.schema_search_path
25
+ connection.schema_search_path = path
26
+ connection.clear_query_cache
27
+ end
28
+ end
29
+ end
30
+ end
data/lib/cell.rb CHANGED
@@ -1,5 +1,7 @@
1
- require "cell/version"
1
+ require 'cell/version'
2
+ require 'cell/configure'
2
3
 
3
- module Cell
4
- # Your code goes here...
4
+ if defined?(Rails)
5
+ require 'cell/migration'
6
+ require 'cell/railtie'
5
7
  end
@@ -0,0 +1,25 @@
1
+ # TenantRoutingManager contains methods that can locate URLs and such for
2
+ # a given tenant.
3
+
4
+ module TenantRoutingManager
5
+ extend ActiveSupport::Concern
6
+
7
+ class_methods do
8
+ def slug_from_host(host)
9
+ if host && (match = host.match(/\A([a-z][a-z0-9-]*)\./))
10
+ return match[1]
11
+ end
12
+ return nil
13
+ end
14
+
15
+ def from_slug(slug)
16
+ return friendly.find(slug)
17
+ rescue ActiveRecord::RecordNotFound
18
+ return nil
19
+ end
20
+
21
+ def from_host(host)
22
+ from_slug(slug_from_host(host))
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,17 @@
1
+ module TenantUrlOptions
2
+ module_function \
3
+ def default_url_options
4
+ r = {}
5
+ r[:protocol] = 'https' if Rails.env.production?
6
+ r[:port] = 3000 unless Rails.env.production?
7
+ r[:host] = Tenant.current.try(:host)
8
+ r
9
+ end
10
+
11
+ end
12
+
13
+ class ActionMailer::Base
14
+ def default_url_options
15
+ TenantUrlOptions.default_url_options
16
+ end
17
+ end
metadata CHANGED
@@ -1,15 +1,63 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cell
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Owens
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2016-09-01 00:00:00.000000000 Z
11
+ date: 2016-10-08 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 5.0.0
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: 5.0.0.1
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - "~>"
28
+ - !ruby/object:Gem::Version
29
+ version: 5.0.0
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 5.0.0.1
33
+ - !ruby/object:Gem::Dependency
34
+ name: pg
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: 0.18.4
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: 0.18.4
47
+ - !ruby/object:Gem::Dependency
48
+ name: minitest
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: 5.9.0
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: 5.9.0
13
61
  - !ruby/object:Gem::Dependency
14
62
  name: bundler
15
63
  requirement: !ruby/object:Gem::Requirement
@@ -52,9 +100,25 @@ files:
52
100
  - Rakefile
53
101
  - bin/console
54
102
  - bin/setup
103
+ - bin/test
55
104
  - cell.gemspec
56
105
  - lib/cell.rb
106
+ - lib/cell/clone_schema.rb
107
+ - lib/cell/configure.rb
108
+ - lib/cell/console.rb
109
+ - lib/cell/context.rb
110
+ - lib/cell/data_directory.rb
111
+ - lib/cell/migration.rb
112
+ - lib/cell/model.rb
113
+ - lib/cell/railtie.rb
114
+ - lib/cell/sanity_check.rb
115
+ - lib/cell/schema.rb
116
+ - lib/cell/sidekiq.rb
117
+ - lib/cell/tasks/cell.rake
57
118
  - lib/cell/version.rb
119
+ - lib/cell/with_schema.rb
120
+ - lib/dump/tenant_routing_manager.rb
121
+ - lib/dump/tenant_url_options.rb
58
122
  homepage: https://github.com/metermd/cell
59
123
  licenses:
60
124
  - MIT