pg_spec_helper 1.0.0 → 1.1.0

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
  SHA256:
3
- metadata.gz: 8b0b5b3c3620c2ddbf235b94c07c33e9c4242ba5221c5d92997c0504ec550448
4
- data.tar.gz: e71a455e469526f1bfe9ef599e4dd37a3dea1e49d0fd18c11ac4cf11035c4a6d
3
+ metadata.gz: 682ffdd64498aa86215c2af590284dfb04be55dcfaf1b70bfe5f5f88a5475d2c
4
+ data.tar.gz: 7fdd1581c726301dd48c23d9b386f78823177418e581ad76f042cea87d4583e1
5
5
  SHA512:
6
- metadata.gz: e433948e243dfdce36e6d6bdd894a18b1bcd9e30b3f622bd4ea2788af1366525c8384c33fff2eeb426548727c85c74c6ce42ce5e3e9006102fd018cbbf068bda
7
- data.tar.gz: bdbbbb5df25c8456ec9c09aed3b5f9eae6eb869cd8c2b0395a031613beb744477a7f59cc47474b10b35daed5b6fda9a11111fcd71fd5e18d3f188cce0c26c475
6
+ metadata.gz: f977a9357f41a826585e15a9946d3769cbd33ecd9f592368cdc8b80276a243474a6cabb7d3ccdb170baf0565e62b1d5c2144ed75a4b5159620de7f65cdfc245c
7
+ data.tar.gz: c880c669479515f49910e7855ca8f90ad6f4a24cc5d48859bc9203fae22852ecdd9345622249f61ee6fcd6f859402ac3ce73d94372e0542b60ee8d847e4e7ba0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.1.0](https://github.com/craigulliott/pg_spec_helper/compare/v1.0.0...v1.1.0) (2023-07-10)
4
+
5
+
6
+ ### Features
7
+
8
+ * documentation, get methods to retrieve current database structure, and full test coverage ([d6c6230](https://github.com/craigulliott/pg_spec_helper/commit/d6c623055d3ac2920bdc4f805973df7f25208329))
9
+
3
10
  ## 1.0.0 (2023-07-08)
4
11
 
5
12
 
data/README.md CHANGED
@@ -1,17 +1,36 @@
1
1
  # PGSpecHelper
2
2
 
3
- Monitor and generate database migrations based on difference between current schema and configuration.
4
3
 
5
4
  [![Gem Version](https://badge.fury.io/rb/pg_spec_helper.svg)](https://badge.fury.io/rb/pg_spec_helper)
6
5
  [![Specs](https://github.com/craigulliott/pg_spec_helper/actions/workflows/specs.yml/badge.svg)](https://github.com/craigulliott/pg_spec_helper/actions/workflows/specs.yml)
7
6
  [![Types](https://github.com/craigulliott/pg_spec_helper/actions/workflows/types.yml/badge.svg)](https://github.com/craigulliott/pg_spec_helper/actions/workflows/types.yml)
8
7
  [![Coding Style](https://github.com/craigulliott/pg_spec_helper/actions/workflows/linter.yml/badge.svg)](https://github.com/craigulliott/pg_spec_helper/actions/workflows/linter.yml)
9
8
 
9
+
10
+ ### What this gem is
11
+
12
+ A helper class for setting up and easily tearing down PostgreSQL database **structure** within a testing environment.
13
+
14
+ If you are building something which depends on specific database structure, and that structure changes depending on specific tests within your test suite, then this gem is for you.
15
+
16
+ For example, [platformer](https://www.github.com/craigulliott/platformer) and [dynamic_migrations](https://www.github.com/craigulliott/dynamic_migrations) are two packages which make use of this gem.
17
+
18
+
19
+ ### What this gem is not
20
+
21
+ This gem is concerned with the **structure** of your database, not the data/records within your database. If you are looking for a gem which can add data/records to your database within your test suite, then check out [factory_bot](https://github.com/thoughtbot/factory_bot). If you are looking for a tool to reset the state of your database (clear records) after your test suite has completed, then check out [database_cleaner](https://github.com/DatabaseCleaner/database_cleaner).
22
+
23
+
10
24
  ## Key Features
11
25
 
12
- * Create tables, columns, constraints, indexes and primary/foreign keys
26
+ * Easily create basic tables, columns, constraints, indexes and primary/foreign keys for your specs
27
+ * Provides convenient methods for testing the presence of various database objects
13
28
  * Resets your database after each spec, only if the spec made changes
14
-
29
+ * Ignores `information_schema` and any schemas or tables beginning with `pg_`
30
+ * Configurable to ignore other schemas (such as `postgis`)
31
+ * Automatically resets and recreates the `public` schema
32
+ * Can track and refresh materialized views
33
+ * Easily access the `PG::Connection` object via `pg_spec_helper.connection` to execute your own SQL
15
34
 
16
35
  ## Installation
17
36
 
@@ -29,11 +48,88 @@ Note, this gem depends on the postgres gem `pg`, which depends on the `libpq` pa
29
48
  # required for pg gem on apple silicon
30
49
  brew install libpq
31
50
  export PATH="/opt/homebrew/opt/libpq/bin:$PATH"
32
- ``
51
+ ```
33
52
 
34
53
  ## Getting Started
35
54
 
36
- Todo
55
+ #### Setting up rspec
56
+
57
+ Install PG Spec Helper into your `spec/spec_helper.rb`
58
+
59
+ ```ruby
60
+ require "pg_spec_helper"
61
+
62
+ RSpec.configure do |config|
63
+
64
+ # make pg_spec_helper conveniently accessable within your test suite
65
+ config.add_setting :pg_spec_helper
66
+ config.pg_spec_helper = PGSpecHelper.new(database: :my_database, host: :localhost, port: 5432, username: 'username', password: '**********')
67
+
68
+ # optionally add additional schemas which should be ignored
69
+ config.pg_spec_helper.ignore_schema :postgis
70
+ config.pg_spec_helper.ignore_schema :some_other_schema
71
+
72
+ # If your package uses materialized views which need to be
73
+ # refreshed after structural changes have occured to your database,
74
+ # then you can track them and refresh them automatically
75
+ #
76
+ # here, the materialized view `my_materialized_view` will be refreshed
77
+ # automatically after any calls to create_schema, or create_table
78
+ config.pg_spec_helper.track_materialized_view :public, :my_materialized_view, [
79
+ :create_schema,
80
+ :create_table
81
+ ]
82
+
83
+ # assert that your test suite is empty before running the test suite
84
+ config.before(:suite) do
85
+ # optionally provide DYNAMIC_MIGRATIONS_CLEAR_DB_ON_STARTUP=true to
86
+ # force reset your database structure
87
+ if ENV["DYNAMIC_MIGRATIONS_CLEAR_DB_ON_STARTUP"]
88
+ config.pg_spec_helper.reset! true
89
+ else
90
+ # raise an error unless your database structure is empty
91
+ config.pg_spec_helper.assert_database_empty!
92
+ end
93
+ end
94
+
95
+ # reset your database structure after each test (this deletes all
96
+ # schemas and tables and then recreates the `public` schema)
97
+ config.after(:each) do
98
+ config.pg_spec_helper.reset!
99
+ end
100
+ end
101
+
102
+ ```
103
+
104
+ The configuration above will assert that your database is completely empty before the test suite runs. If your database is not empty, then an error will be raised.
105
+
106
+ If rspec crashed or exited prematurely on the last execution of your test suite, then you can tell pg_spec_helper to forcefully clear your database.
107
+
108
+ `DYNAMIC_MIGRATIONS_CLEAR_DB_ON_STARTUP=true bundle exec rspec`
109
+
110
+ #### An example test which requires some specific structure
111
+
112
+ ```ruby
113
+ RSpec.describe PGSpecHelper do
114
+ let(:pg_spec_helper) { RSpec.configuration.pg_spec_helper }
115
+
116
+ describe 'where the table `my_schema`.`my_table` exists in the database' do
117
+ before(:each) do
118
+ pg_spec_helper.create_schema :my_schema
119
+ pg_spec_helper.create_table :my_schema, :my_table
120
+ pg_spec_helper.create_column :my_schema, :my_table, :my_column, :integer
121
+ pg_spec_helper.create_primary_key :my_schema, :my_schema, :my_table, [:my_column]
122
+ end
123
+
124
+ it "test something which required that table to exist" do
125
+ expect().to_not raise_error
126
+ end
127
+ end
128
+ end
129
+ ```
130
+
131
+
132
+ See https://github.com/craigulliott/pg_spec_helper/tree/main/lib/pg_spec_helper for all the methods which are available on this class.
37
133
 
38
134
 
39
135
  ## Development
@@ -2,18 +2,25 @@
2
2
 
3
3
  class PGSpecHelper
4
4
  module Columns
5
+ # create a column for the provided table
5
6
  def create_column schema_name, table_name, column_name, type
6
- # validate the type exists
7
- PGSpecHelper::Postgres::DataTypes.validate_type_exists! type
8
7
  # note the `type` is safe from sql_injection due to the validation above
9
8
  connection.exec(<<-SQL)
10
9
  ALTER TABLE #{connection.quote_ident schema_name.to_s}.#{connection.quote_ident table_name.to_s}
11
- ADD COLUMN #{connection.quote_ident column_name.to_s} #{type}
10
+ ADD COLUMN #{connection.quote_ident column_name.to_s} #{sanitize_name type}
12
11
  SQL
13
- # refresh the cached representation of the database structure
14
- refresh_structure_cache_materialized_view
15
- # note that the database has been reset and there are no changes
16
- @has_changes = true
12
+ end
13
+
14
+ # return an array of column names for the provided table
15
+ def get_column_names schema_name, table_name
16
+ rows = connection.exec_params(<<-SQL, [schema_name.to_s, table_name.to_s])
17
+ SELECT column_name
18
+ FROM information_schema.columns
19
+ WHERE table_schema = $1
20
+ AND table_name = $2
21
+ ORDER BY ordinal_position;
22
+ SQL
23
+ rows.map { |row| row["column_name"].to_sym }
17
24
  end
18
25
  end
19
26
  end
@@ -6,20 +6,14 @@ class PGSpecHelper
6
6
  end
7
7
 
8
8
  def connection
9
- unless @connection
10
- @connection = PG.connect(
11
- host: @host,
12
- port: @port,
13
- user: @username,
14
- password: @password,
15
- dbname: @database,
16
- sslmode: "prefer"
17
- )
18
- # after initial connect, we refresh the cached representation of
19
- # the database structure and constaints
20
- refresh_structure_cache_materialized_view
21
- refresh_validation_cache_materialized_view
22
- end
9
+ @connection ||= PG.connect(
10
+ host: @host,
11
+ port: @port,
12
+ user: @username,
13
+ password: @password,
14
+ dbname: @database,
15
+ sslmode: "prefer"
16
+ )
23
17
  @connection
24
18
  end
25
19
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ class PGSpecHelper
4
+ module EmptyDatabase
5
+ class DatabaseNotEmptyError < StandardError
6
+ end
7
+
8
+ # We assert there are no schemas before we run the test suite because it
9
+ # requires starting with an empty database.
10
+ #
11
+ # We could clear it automatically here, but don't want to risk deleting data
12
+ # in case the configuration is wrong and this is connected to an unexpected
13
+ # database.
14
+ def assert_database_empty!
15
+ public_schema_exists = false
16
+ get_schema_names.each do |schema_name|
17
+ # we expect the public schema to exist, but assert that it is empty later
18
+ if schema_name == :public
19
+ public_schema_exists = true
20
+ next
21
+ end
22
+ raise DatabaseNotEmptyError, "Expected no schemas to exist, but found `#{schema_name}` in `#{@database}` on `#{@host}`. Your test suite might have failed to complete the last time it was run. Please delete all schemas. If you are certain this is pointed at the correct database, then you can set DYNAMIC_MIGRATIONS_CLEAR_DB_ON_STARTUP=true on your console and execute these specs again to automatically clear the database."
23
+ end
24
+ # assert that the public schema exists
25
+ raise DatabaseNotEmptyError, "Public schema does not exist" unless public_schema_exists
26
+ # assert the public schema has no tables
27
+ raise DatabaseNotEmptyError, "Public schema is not empty" unless get_table_names(:public).empty?
28
+ # the database is empty, return true
29
+ true
30
+ end
31
+ end
32
+ end
@@ -2,20 +2,30 @@
2
2
 
3
3
  class PGSpecHelper
4
4
  module ForeignKeys
5
+ # Create a foreign key
5
6
  def create_foreign_key schema_name, table_name, column_names, foreign_schema_name, foreign_table_name, foreign_column_names, foreign_key_name
6
- column_names_sql = column_names.map { |n| connection.quote_ident n.to_s }.join(", ")
7
- foreign_column_names_sql = column_names.map { |n| connection.quote_ident n.to_s }.join(", ")
7
+ column_names_sql = column_names.map { |n| sanitize_name n }.join(", ")
8
+ foreign_column_names_sql = foreign_column_names.map { |n| sanitize_name n }.join(", ")
8
9
  # add the foreign key
9
10
  connection.exec(<<-SQL)
10
- ALTER TABLE #{connection.quote_ident schema_name.to_s}.#{connection.quote_ident table_name.to_s}
11
- ADD CONSTRAINT #{connection.quote_ident foreign_key_name.to_s}
11
+ ALTER TABLE #{sanitize_name schema_name}.#{sanitize_name table_name}
12
+ ADD CONSTRAINT #{sanitize_name foreign_key_name}
12
13
  FOREIGN KEY (#{column_names_sql})
13
- REFERENCES #{connection.quote_ident foreign_schema_name.to_s}.#{connection.quote_ident foreign_table_name.to_s} (#{foreign_column_names_sql})
14
+ REFERENCES #{sanitize_name foreign_schema_name}.#{sanitize_name foreign_table_name} (#{foreign_column_names_sql})
14
15
  SQL
15
- # refresh the cached representation of the database foreign keys
16
- refresh_keys_and_unique_constraints_cache_materialized_view
17
- # note that the database has been reset and there are no changes
18
- @has_changes = true
16
+ end
17
+
18
+ # returns a list of foreign keys for the provided table
19
+ def get_foreign_key_names schema_name, table_name
20
+ rows = connection.exec_params(<<-SQL, [schema_name.to_s, table_name.to_s])
21
+ SELECT constraint_name
22
+ FROM information_schema.table_constraints
23
+ WHERE table_schema = $1
24
+ AND table_name = $2
25
+ AND constraint_type = 'FOREIGN KEY'
26
+ ORDER BY constraint_name;
27
+ SQL
28
+ rows.map { |row| row["constraint_name"].to_sym }
19
29
  end
20
30
  end
21
31
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class PGSpecHelper
4
+ module IgnoredSchemas
5
+ # add a schema to the list of ignored schemas
6
+ def ignore_schema schema_name
7
+ @ignored_schemas ||= []
8
+ @ignored_schemas << schema_name.to_sym
9
+ end
10
+
11
+ # get a list of ignored schemas
12
+ def ignored_schemas
13
+ ([:information_schema] + (@ignored_schemas || []))
14
+ end
15
+ end
16
+ end
@@ -2,15 +2,25 @@
2
2
 
3
3
  class PGSpecHelper
4
4
  module Indexes
5
- def create_index schema_name, table_name, column_names, index_name, type, deferrable, initially_deferred
5
+ # Create an index
6
+ def create_index schema_name, table_name, column_names, index_name
7
+ column_names_sql = column_names.map { |n| sanitize_name n }.join(", ")
6
8
  connection.exec(<<-SQL)
7
9
  CREATE INDEX #{connection.quote_ident index_name.to_s}
8
- ON #{connection.quote_ident schema_name.to_s}.#{connection.quote_ident table_name.to_s} (b, c)
10
+ ON #{connection.quote_ident schema_name.to_s}.#{connection.quote_ident table_name.to_s} (#{column_names_sql})
9
11
  SQL
10
- # refresh the cached representation of the database indexes
11
- refresh_index_cache_materialized_view
12
- # note that the database has been reset and there are no changes
13
- @has_changes = true
12
+ end
13
+
14
+ # get a list of index names for the provided table
15
+ def get_index_names schema_name, table_name
16
+ rows = connection.exec_params(<<-SQL, [schema_name.to_s, table_name.to_s])
17
+ SELECT indexname
18
+ FROM pg_indexes
19
+ WHERE schemaname = $1
20
+ AND tablename = $2
21
+ ORDER BY indexname;
22
+ SQL
23
+ rows.map { |row| row["indexname"].to_sym }
14
24
  end
15
25
  end
16
26
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ class PGSpecHelper
4
+ module MaterializedViews
5
+ class MaterializedViewNotTrackedError < StandardError
6
+ end
7
+
8
+ # add the name of a materialized view to a list of views which will
9
+ # be refreshed after each type of change
10
+ def track_materialized_view schema_name, materialized_view_name, refresh_after
11
+ @materialized_views ||= {}
12
+ @materialized_views[schema_name.to_sym] ||= {}
13
+
14
+ # ensure the refresh_after contains at least one trackable method
15
+ unless refresh_after.is_a?(Array) && refresh_after.count
16
+ raise ArgumentError, "refresh_after must be an array of trackable method names"
17
+ end
18
+
19
+ # ensure each method in the refresh_after list is trackable
20
+ refresh_after.each do |method_name|
21
+ assert_trackable_method_name! method_name
22
+ end
23
+
24
+ @materialized_views[schema_name.to_sym][materialized_view_name.to_sym] = {
25
+ # assume does not exist until proven otherwise
26
+ exists: false,
27
+ # list of methods which should trigger a refresh of this
28
+ # materialized view
29
+ refresh_after: refresh_after
30
+ }
31
+ end
32
+
33
+ private
34
+
35
+ # return true if the materialized view exists, otherwise false
36
+ def materialized_view_exists? schema_name, materialized_view_name
37
+ # assert this materialized view is being tracked
38
+ assert_materialized_view_tracked! schema_name, materialized_view_name
39
+
40
+ # return true if we've already determined that the materialized view exists
41
+ # otherwise, check the database and cache the result of the existance check
42
+ #
43
+ # the check will be made every time the method is called, until the first
44
+ # time the materialized view is found to exist, at which point the method
45
+ # will always return true
46
+ @materialized_views[schema_name.to_sym][materialized_view_name.to_sym][:exists] ||= connection.exec(<<~SQL).count > 0
47
+ SELECT TRUE AS exists FROM pg_matviews WHERE schemaname = '#{sanitize_name schema_name}' AND matviewname = '#{sanitize_name materialized_view_name}';
48
+ SQL
49
+ end
50
+
51
+ # refresh all materialized views that have been tracked
52
+ def refresh_all_materialized_views
53
+ @materialized_views&.each do |schema_name, views|
54
+ views.each do |materialized_view_name, view|
55
+ if materialized_view_exists? schema_name, materialized_view_name
56
+ refresh_materialized_view schema_name, materialized_view_name
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ # whenever schema changes are made from within the test suite we need to
63
+ # rebuild the materialized views that hold a cached representation of the
64
+ # database structure
65
+ def refresh_materialized_view schema_name, materialized_view_name
66
+ # assert this materialized view is being tracked
67
+ assert_materialized_view_tracked! schema_name, materialized_view_name
68
+
69
+ # refresh the view if it exists
70
+ if materialized_view_exists? schema_name, materialized_view_name
71
+ connection.exec(<<~SQL)
72
+ REFRESH MATERIALIZED VIEW #{connection.quote_ident schema_name.to_s}.#{connection.quote_ident materialized_view_name.to_s};
73
+ SQL
74
+ end
75
+ end
76
+
77
+ # assert that the provided materialized view is being tracked
78
+ def assert_materialized_view_tracked! schema_name, materialized_view_name
79
+ # assert any materialized views are being tracked
80
+ if @materialized_views.nil?
81
+ raise MaterializedViewNotTrackedError, "no materialized views are being tracked"
82
+ end
83
+ # assert this materialized view is being tracked
84
+ unless @materialized_views.key? schema_name.to_sym
85
+ raise MaterializedViewNotTrackedError, "no materialized views for schema `#{schema_name}` are being tracked"
86
+ end
87
+ # assert this materialized view is being tracked
88
+ unless @materialized_views[schema_name.to_sym].key? materialized_view_name.to_sym
89
+ raise MaterializedViewNotTrackedError, "materialized view `#{schema_name}`.`#{materialized_view_name}` is not being tracked"
90
+ end
91
+ # the materialized view does exist, so return true
92
+ true
93
+ end
94
+ end
95
+ end
@@ -3,17 +3,30 @@
3
3
  class PGSpecHelper
4
4
  module PrimaryKeys
5
5
  # add a primary_key to the provided table which covers the provided columns
6
- def add_primary_key schema_name, table_name, column_names
6
+ def create_primary_key schema_name, table_name, column_names, primary_key_name
7
7
  column_names_sql = column_names.map { |n| connection.quote_ident n.to_s }.join(", ")
8
8
  # add the primary_key
9
9
  connection.exec(<<-SQL)
10
- ALTER TABLE #{connection.quote_ident schema_name.to_s}.#{connection.quote_ident table_name.to_s}
11
- ADD PRIMARY KEY (#{column_names_sql})
10
+ ALTER TABLE #{sanitize_name schema_name.to_s}.#{sanitize_name table_name.to_s}
11
+ ADD CONSTRAINT #{sanitize_name primary_key_name}
12
+ PRIMARY KEY (#{column_names_sql})
12
13
  SQL
13
- # refresh the cached representation of the database primary_key keys
14
- refresh_keys_and_unique_constraints_cache_materialized_view
15
- # note that the database has been reset and there are no changes
16
- @has_changes = true
14
+ end
15
+
16
+ # get the primary_key name for the provided table
17
+ def get_primary_key_name schema_name, table_name
18
+ # get the primary_key name
19
+ rows = connection.exec(<<-SQL, [schema_name.to_s, table_name.to_s])
20
+ SELECT
21
+ constraint_name
22
+ FROM
23
+ information_schema.table_constraints
24
+ WHERE
25
+ table_schema = $1
26
+ AND table_name = $2
27
+ AND constraint_type = 'PRIMARY KEY'
28
+ SQL
29
+ rows.map { |r| r["constraint_name"].to_sym }.first
17
30
  end
18
31
  end
19
32
  end
@@ -0,0 +1,12 @@
1
+ class PGSpecHelper
2
+ module Reset
3
+ # reset the database to its original state
4
+ def reset! force = false
5
+ if force || has_changes?
6
+ delete_all_schemas cascade: true
7
+ # reset the tracking of changes
8
+ @methods_used = {}
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class PGSpecHelper
4
+ module Sanitize
5
+ class UnsafePostgresNameError < StandardError
6
+ end
7
+
8
+ # returns a sanitized version of the provided name, raises an error
9
+ # if it is not valid
10
+ #
11
+ # this is probably unnessesary due to this gem being used within a test
12
+ # suite and not a production application, but it is here for completeness
13
+ # and an abundance of caution
14
+ def sanitize_name name
15
+ # ensure the name is a string
16
+ name = name.to_s
17
+ # ensure the name is not empty
18
+ raise UnsafePostgresNameError, "name cannot be empty" if name.empty?
19
+ # ensure the name does not contain invalid characters
20
+ raise UnsafePostgresNameError, "name contains invalid characters" unless /\A[a-zA-Z0-9_-]+\z/.match?(name)
21
+ # return the name
22
+ name
23
+ end
24
+ end
25
+ end
@@ -2,66 +2,46 @@
2
2
 
3
3
  class PGSpecHelper
4
4
  module Schemas
5
- # We assert there are no schemas before we run the test suite because it
6
- # requires starting with an empty database.
7
- #
8
- # We could clear it automatically here, but don't want to risk deleting data
9
- # in case the configuration is wrong and this is connected to an unexpected
10
- # database.
11
- def assert_database_empty!
12
- public_schema_exists = false
13
- get_schema_names.each do |schema_name|
14
- if schema_name == "public"
15
- public_schema_exists = true
16
- next
17
- end
18
- next if schema_name.start_with? "pg_"
19
- next if schema_name == "postgis"
20
- next if schema_name == "information_schema"
21
- raise "Expected no schemas to exist, but found `#{schema_name}` in `#{@database}` on `#{@host}`. Your test suite might have failed to complete the last time it was run. Please delete all schemas. If you are certain this is pointed at the correct database, then you can set DYNAMIC_MIGRATIONS_CLEAR_DB_ON_STARTUP=true on your console and execute these specs again to automatically clear the database."
22
- end
23
- # assert that the public schema exists
24
- raise "Public schema does not exist" unless public_schema_exists
25
- # assert the public schema has no tables
26
- raise "Public scheama is not empty" unless get_table_names(:public).empty?
27
- end
28
-
5
+ # create a new schema in the database
29
6
  def create_schema schema_name
30
7
  connection.exec(<<-SQL)
31
8
  CREATE SCHEMA #{connection.quote_ident schema_name.to_s};
32
9
  SQL
33
- # refresh the cached representation of the database structure
34
- refresh_structure_cache_materialized_view
35
- # note that the structure has changed, so that the database can be reset between tests
36
- @has_changes = true
37
10
  end
38
11
 
12
+ # return a list of the schema names in the database
39
13
  def get_schema_names
14
+ ignored_schemas_sql = ignored_schemas.map { |n| sanitize_name n }.join("', '")
15
+ # return a list of the schema names from the database
40
16
  results = connection.exec(<<-SQL)
41
17
  SELECT schema_name
42
- FROM information_schema.schemata;
18
+ FROM information_schema.schemata
19
+ WHERE
20
+ schema_name NOT IN ('#{ignored_schemas_sql}')
21
+ AND schema_name NOT LIKE 'pg_%';
43
22
  SQL
44
- schema_names = results.map { |row| row["schema_name"] }
23
+ schema_names = results.map { |row| row["schema_name"].to_sym }
45
24
  schema_names.sort
46
25
  end
47
26
 
27
+ # delete all schemas in the database
48
28
  def delete_all_schemas cascade: false
29
+ # delete all schemas
49
30
  get_schema_names.each do |schema_name|
50
- next if schema_name.start_with? "pg_"
51
- next if schema_name == "postgis"
52
- next if schema_name == "public"
53
- next if schema_name == "information_schema"
54
31
  connection.exec(<<-SQL)
32
+ -- temporarily set the client_min_messages to WARNING to
33
+ -- suppress the NOTICE messages about cascading deletes
34
+ SET client_min_messages TO WARNING;
55
35
  DROP SCHEMA #{connection.quote_ident schema_name.to_s} #{cascade ? "CASCADE" : ""};
36
+ SET client_min_messages TO NOTICE;
56
37
  SQL
57
38
  end
58
- # refresh the cached representation of the database structure
59
- # as removing objects can affect them
60
- refresh_structure_cache_materialized_view
61
- refresh_validation_cache_materialized_view
62
- refresh_keys_and_unique_constraints_cache_materialized_view
63
- # note that the database has been reset and there are no changes
64
- @has_changes = true
39
+ # recreate the default `public` schema
40
+ create_schema :public
41
+ end
42
+
43
+ def schema_exists? schema_name
44
+ get_schema_names.include? schema_name.to_sym
65
45
  end
66
46
  end
67
47
  end
@@ -2,30 +2,36 @@
2
2
 
3
3
  class PGSpecHelper
4
4
  module Tables
5
+ # create a new table in the provided schema
5
6
  def create_table schema_name, table_name
6
7
  connection.exec(<<-SQL)
7
- CREATE TABLE #{connection.quote_ident schema_name.to_s}.#{connection.quote_ident table_name.to_s}(
8
+ CREATE TABLE #{sanitize_name schema_name.to_s}.#{sanitize_name table_name.to_s}(
8
9
  -- tables are created empty, and have columns added to them later
9
10
  );
10
11
  SQL
11
- # refresh the cached representation of the database structure
12
- refresh_structure_cache_materialized_view
13
- # note that the structure has changed, so that the database can be reset between tests
14
- @has_changes = true
15
12
  end
16
13
 
14
+ # return an array of table names for the provided schema
17
15
  def get_table_names schema_name
18
16
  rows = connection.exec_params(<<-SQL, [schema_name.to_s])
19
17
  SELECT table_name FROM information_schema.tables
20
- WHERE table_schema = $1
18
+ WHERE
19
+ table_schema = $1
20
+ AND table_name NOT LIKE 'pg_%';
21
21
  SQL
22
- rows.map { |row| row["table_name"] }
22
+ table_names = rows.map { |row| row["table_name"].to_sym }
23
+ table_names.sort
23
24
  end
24
25
 
26
+ # delete all tables in the provided schema
25
27
  def delete_tables schema_name
26
28
  get_table_names(schema_name).each do |table_name|
27
29
  connection.exec(<<-SQL)
28
- DROP TABLE #{connection.quote_ident schema_name.to_s}.#{connection.quote_ident table_name.to_s} CASCASE
30
+ -- temporarily set the client_min_messages to WARNING to
31
+ -- suppress the NOTICE messages about cascading deletes
32
+ SET client_min_messages TO WARNING;
33
+ DROP TABLE #{sanitize_name schema_name.to_s}.#{sanitize_name table_name.to_s} CASCADE;
34
+ SET client_min_messages TO NOTICE;
29
35
  SQL
30
36
  end
31
37
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ class PGSpecHelper
4
+ module TrackChanges
5
+ class UntrackableMethodNameError < StandardError
6
+ end
7
+
8
+ # this is a list of methods that have their useage tracked, this
9
+ # is used to determine what actions need to be taken when calling
10
+ # reset! between tests
11
+ TRACKED_METHOD_CALLS = [
12
+ :create_schema,
13
+ :create_table,
14
+ :create_column
15
+ ]
16
+
17
+ # returns true if any changes have been made to the database structure
18
+ # optionally pass in a method name to check if that specific method was used
19
+ def has_changes? method_name = nil
20
+ if method_name.nil?
21
+ methods_used.keys.count > 0
22
+ else
23
+ assert_trackable_method_name! method_name
24
+ methods_used[method_name] || false
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ # track that the provided method name has been used, this allows us to determine later
31
+ # which methods have been used and what actions need to be taken to reset the database
32
+ def track_change method_name
33
+ assert_trackable_method_name! method_name
34
+ @methods_used ||= {}
35
+ @methods_used[method_name] ||= true
36
+ end
37
+
38
+ # returns a hash representation of methods which have been used
39
+ def methods_used
40
+ @methods_used || {}
41
+ end
42
+
43
+ # raises an error if the provided method name is not trackable, otherwise returns true
44
+ def assert_trackable_method_name! method_name
45
+ unless TRACKED_METHOD_CALLS.include? method_name.to_sym
46
+ raise UntrackableMethodNameError, "method `#{method_name}` is not trackable"
47
+ end
48
+ true
49
+ end
50
+
51
+ # overide the trackable methods and record when they are used
52
+ # this allows us to determine what actions need to be taken
53
+ # to reset the datasbe between tests
54
+ def install_trackable_methods
55
+ TRACKED_METHOD_CALLS.each do |method_name|
56
+ # keep a pointer to the original method
57
+ original_method = self.class.instance_method(method_name)
58
+ # ovveride the original method
59
+ self.class.define_method(method_name) do |*args|
60
+ # note that this method was called
61
+ track_change method_name
62
+ # call the original method
63
+ original_method.bind_call(self, *args)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -3,7 +3,7 @@
3
3
  class PGSpecHelper
4
4
  module UniqueConstraints
5
5
  # add a unique constraint to the provided table and columns
6
- def add_unique_constraint schema_name, table_name, column_names, constraint_key_name
6
+ def create_unique_constraint schema_name, table_name, column_names, constraint_key_name
7
7
  column_names_sql = column_names.map { |n| connection.quote_ident n.to_s }.join(", ")
8
8
  # add the constraint key
9
9
  connection.exec(<<-SQL)
@@ -11,10 +11,22 @@ class PGSpecHelper
11
11
  ADD CONSTRAINT #{connection.quote_ident constraint_key_name.to_s}
12
12
  UNIQUE (#{column_names_sql})
13
13
  SQL
14
- # refresh the cached representation of the database constraint keys
15
- refresh_keys_and_unique_constraints_cache_materialized_view
16
- # note that the database has been reset and there are no changes
17
- @has_changes = true
14
+ end
15
+
16
+ # get a list of unique constraints for the provided table
17
+ def get_unique_constraint_names schema_name, table_name
18
+ # get the unique constraint names
19
+ rows = connection.exec(<<-SQL, [schema_name.to_s, table_name.to_s])
20
+ SELECT
21
+ constraint_name
22
+ FROM
23
+ information_schema.table_constraints
24
+ WHERE
25
+ table_schema = $1
26
+ AND table_name = $2
27
+ AND constraint_type = 'UNIQUE'
28
+ SQL
29
+ rows.map { |r| r["constraint_name"].to_sym }
18
30
  end
19
31
  end
20
32
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  class PGSpecHelper
4
4
  module Validations
5
+ # create a validation on the provided table and columns
5
6
  def create_validation schema_name, table_name, validation_name, check_clause
6
7
  # todo the check_clause is vulnerable to sql injection (although this is very low risk because
7
8
  # it is only ever provided by the test suite, and is never provided by the user)
@@ -9,10 +10,22 @@ class PGSpecHelper
9
10
  ALTER TABLE #{connection.quote_ident schema_name.to_s}.#{connection.quote_ident table_name.to_s}
10
11
  ADD CONSTRAINT #{connection.quote_ident validation_name.to_s} CHECK (#{check_clause})
11
12
  SQL
12
- # refresh the cached representation of the database structure
13
- refresh_validation_cache_materialized_view
14
- # note that the database has been reset and there are no changes
15
- @has_changes = true
13
+ end
14
+
15
+ # return a list of validation names for the provided table
16
+ def get_validation_names schema_name, table_name
17
+ # get the validation names
18
+ rows = connection.exec(<<-SQL, [schema_name.to_s, table_name.to_s])
19
+ SELECT
20
+ constraint_name
21
+ FROM
22
+ information_schema.table_constraints
23
+ WHERE
24
+ table_schema = $1
25
+ AND table_name = $2
26
+ AND constraint_type = 'CHECK'
27
+ SQL
28
+ rows.map { |r| r["constraint_name"].to_sym }
16
29
  end
17
30
  end
18
31
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class PGSpecHelper
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.0"
5
5
  end
@@ -5,8 +5,9 @@ require "pg"
5
5
 
6
6
  require "pg_spec_helper/version"
7
7
 
8
- require "pg_spec_helper/configuration"
9
8
  require "pg_spec_helper/connection"
9
+ require "pg_spec_helper/sanitize"
10
+ require "pg_spec_helper/ignored_schemas"
10
11
  require "pg_spec_helper/schemas"
11
12
  require "pg_spec_helper/tables"
12
13
  require "pg_spec_helper/columns"
@@ -15,14 +16,18 @@ require "pg_spec_helper/foreign_keys"
15
16
  require "pg_spec_helper/unique_constraints"
16
17
  require "pg_spec_helper/primary_keys"
17
18
  require "pg_spec_helper/indexes"
18
- require "pg_spec_helper/validation_cache"
19
- require "pg_spec_helper/structure_cache"
20
- require "pg_spec_helper/foreign_key_cache"
21
- require "pg_spec_helper/index_cache"
19
+ require "pg_spec_helper/materialized_views"
20
+ require "pg_spec_helper/reset"
21
+ require "pg_spec_helper/empty_database"
22
+ require "pg_spec_helper/track_changes"
22
23
 
23
24
  class PGSpecHelper
24
- include Configuration
25
+ class MissingRequiredOptionError < StandardError
26
+ end
27
+
25
28
  include Connection
29
+ include Sanitize
30
+ include IgnoredSchemas
26
31
  include Schemas
27
32
  include Tables
28
33
  include Columns
@@ -31,36 +36,34 @@ class PGSpecHelper
31
36
  include UniqueConstraints
32
37
  include PrimaryKeys
33
38
  include Indexes
34
- include ValidationCache
35
- include StructureCache
36
- include ForeignKeyCache
37
- include IndexCache
39
+ include MaterializedViews
40
+ include Reset
41
+ include EmptyDatabase
42
+ include TrackChanges
38
43
 
39
44
  attr_reader :database, :username, :password, :host, :port
40
45
 
41
- def initialize name
42
- load_configuration_for :postgres, name
43
-
44
- @database = require_configuration_value(:database).to_sym
45
- @host = require_configuration_value :host
46
- @port = require_configuration_value :port
47
- @username = require_configuration_value :username
48
- @password = optional_configuration_value :password
46
+ def initialize database:, username:, host:, port:, password: nil
47
+ # assert that all required options are present
48
+ raise MissingRequiredOptionError, "database is required" if database.nil?
49
+ raise MissingRequiredOptionError, "host is required" if host.nil?
50
+ raise MissingRequiredOptionError, "username is required" if username.nil?
49
51
 
50
- # will be set to true if any changes are made to the database structure
51
- # this is used to determine if the structure needs to be reset between tests
52
- @has_changes = false
53
- end
54
-
55
- def has_changes?
56
- @has_changes
57
- end
52
+ # record the configuration
53
+ @database = database
54
+ @host = host
55
+ @port = port || 5432
56
+ @username = username
57
+ # password is optional
58
+ @password = password
58
59
 
59
- def reset! force = false
60
- if force || @has_changes
61
- delete_all_schemas cascade: true
62
- # note that the database has been reset and there are no changes
63
- @has_changes = false
64
- end
60
+ # the TrackChanges module is used to track high level changes which are
61
+ # made to the database using this class, this allows us to reach out to the
62
+ # database and reset it only when needed.
63
+ #
64
+ # The `install_trackable_methods` method will override the methods which
65
+ # we are tracking with a new proxy method which will record when the method
66
+ # is called before subsequently calling the original method.
67
+ install_trackable_methods
65
68
  end
66
69
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pg_spec_helper
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Craig Ulliott
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-07-08 00:00:00.000000000 Z
11
+ date: 2023-07-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: pg
@@ -52,18 +52,19 @@ files:
52
52
  - README.md
53
53
  - lib/pg_spec_helper.rb
54
54
  - lib/pg_spec_helper/columns.rb
55
- - lib/pg_spec_helper/configuration.rb
56
55
  - lib/pg_spec_helper/connection.rb
57
- - lib/pg_spec_helper/foreign_key_cache.rb
56
+ - lib/pg_spec_helper/empty_database.rb
58
57
  - lib/pg_spec_helper/foreign_keys.rb
59
- - lib/pg_spec_helper/index_cache.rb
58
+ - lib/pg_spec_helper/ignored_schemas.rb
60
59
  - lib/pg_spec_helper/indexes.rb
60
+ - lib/pg_spec_helper/materialized_views.rb
61
61
  - lib/pg_spec_helper/primary_keys.rb
62
+ - lib/pg_spec_helper/reset.rb
63
+ - lib/pg_spec_helper/sanitize.rb
62
64
  - lib/pg_spec_helper/schemas.rb
63
- - lib/pg_spec_helper/structure_cache.rb
64
65
  - lib/pg_spec_helper/tables.rb
66
+ - lib/pg_spec_helper/track_changes.rb
65
67
  - lib/pg_spec_helper/unique_constraints.rb
66
- - lib/pg_spec_helper/validation_cache.rb
67
68
  - lib/pg_spec_helper/validations.rb
68
69
  - lib/pg_spec_helper/version.rb
69
70
  homepage:
@@ -1,51 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class PGSpecHelper
4
- module Configuration
5
- class MissingConfigurationError < StandardError
6
- end
7
-
8
- class ConfigurationNotLoadedError < StandardError
9
- end
10
-
11
- class MissingRequiredNameError < StandardError
12
- end
13
-
14
- class MissingRequiredDatabaseTypeError < StandardError
15
- end
16
-
17
- def load_configuration_for database_type, name
18
- @name = name.to_s
19
- @database_type = database_type.to_s
20
-
21
- raise MissingRequiredNameError unless @name
22
- raise MissingRequiredDatabaseTypeError unless @database_type
23
-
24
- configuration = load_configuration_file
25
-
26
- if configuration[@database_type].nil?
27
- raise MissingConfigurationError, "no database configuration found for #{name} in database.yaml"
28
- end
29
-
30
- if configuration[@database_type][@name].nil?
31
- raise MissingConfigurationError, "no configuration found for #{database_type}.#{name} in database.yaml"
32
- end
33
-
34
- @configuration = configuration[@database_type][@name]
35
- end
36
-
37
- def optional_configuration_value key
38
- @configuration[key.to_s]
39
- end
40
-
41
- def require_configuration_value key
42
- raise ConfigurationNotLoadedError unless @configuration
43
-
44
- @configuration[key.to_s] || raise(MissingConfigurationError, "no #{key} found for configuration #{@database_type}.#{@name}")
45
- end
46
-
47
- def load_configuration_file
48
- YAML.load_file("config/database.yaml")
49
- end
50
- end
51
- end
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class PGSpecHelper
4
- module ForeignKeyCache
5
- # whenever schema changes are made from within the test suite we need to
6
- # rebuild the materialized views that hold a cached representation of the
7
- # database foreign keys
8
- def refresh_keys_and_unique_constraints_cache_materialized_view
9
- # the first time we detect the presence of the marerialized view, we no longer need to
10
- # check for it
11
- @keys_and_unique_constraints_cache_exists ||= keys_and_unique_constraints_cache_exists?
12
- if @keys_and_unique_constraints_cache_exists
13
- connection.exec(<<~SQL)
14
- REFRESH MATERIALIZED VIEW public.pg_spec_helper_keys_and_unique_constraints_cache;
15
- SQL
16
- end
17
- end
18
-
19
- def keys_and_unique_constraints_cache_exists?
20
- exists = connection.exec(<<~SQL)
21
- SELECT TRUE AS exists FROM pg_matviews WHERE schemaname = 'public' AND matviewname = 'pg_spec_helper_keys_and_unique_constraints_cache';
22
- SQL
23
- exists.count > 0
24
- end
25
- end
26
- end
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class PGSpecHelper
4
- module IndexCache
5
- # whenever schema changes are made from within the test suite we need to
6
- # rebuild the materialized views that hold a cached representation of the
7
- # database indexes
8
- def refresh_index_cache_materialized_view
9
- # the first time we detect the presence of the marerialized view, we no longer need to
10
- # check for it
11
- @index_cache_exists ||= index_cache_exists?
12
- if @index_cache_exists
13
- connection.exec(<<~SQL)
14
- REFRESH MATERIALIZED VIEW public.pg_spec_helper_index_cache;
15
- SQL
16
- end
17
- end
18
-
19
- def index_cache_exists?
20
- exists = connection.exec(<<~SQL)
21
- SELECT TRUE AS exists FROM pg_matviews WHERE schemaname = 'public' AND matviewname = 'pg_spec_helper_index_cache';
22
- SQL
23
- exists.count > 0
24
- end
25
- end
26
- end
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class PGSpecHelper
4
- module StructureCache
5
- # whenever schema changes are made from within the test suite we need to
6
- # rebuild the materialized views that hold a cached representation of the
7
- # database structure
8
- def refresh_structure_cache_materialized_view
9
- # the first time we detect the presence of the marerialized view, we no longer need to
10
- # check for it
11
- @structure_cache_exists ||= structure_cache_exists?
12
- if @structure_cache_exists
13
- connection.exec(<<~SQL)
14
- REFRESH MATERIALIZED VIEW public.pg_spec_helper_structure_cache;
15
- SQL
16
- end
17
- end
18
-
19
- def structure_cache_exists?
20
- exists = connection.exec(<<~SQL)
21
- SELECT TRUE AS exists FROM pg_matviews WHERE schemaname = 'public' AND matviewname = 'pg_spec_helper_structure_cache';
22
- SQL
23
- exists.count > 0
24
- end
25
- end
26
- end
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- class PGSpecHelper
4
- module ValidationCache
5
- # whenever schema changes are made from within the test suite we need to
6
- # rebuild the materialized views that hold a cached representation of the
7
- # database structure
8
- def refresh_validation_cache_materialized_view
9
- # the first time we detect the presence of the marerialized view, we no longer need to
10
- # check for it
11
- @validations_cache_exists ||= validations_cache_exists?
12
- if @validations_cache_exists
13
- connection.exec(<<~SQL)
14
- REFRESH MATERIALIZED VIEW public.pg_spec_helper_validations_cache;
15
- SQL
16
- end
17
- end
18
-
19
- def validations_cache_exists?
20
- exists = connection.exec(<<~SQL)
21
- SELECT TRUE AS exists FROM pg_matviews WHERE schemaname = 'public' AND matviewname = 'pg_spec_helper_validations_cache';
22
- SQL
23
- exists.count > 0
24
- end
25
- end
26
- end