cell 0.1.0 → 0.1.1

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: 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