pg_eventstore 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +7 -0
  3. data/CODE_OF_CONDUCT.md +84 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +90 -0
  6. data/db/extensions.sql +2 -0
  7. data/db/indexes.sql +13 -0
  8. data/db/primary_and_foreign_keys.sql +11 -0
  9. data/db/tables.sql +21 -0
  10. data/docs/appending_events.md +170 -0
  11. data/docs/configuration.md +82 -0
  12. data/docs/events_and_streams.md +45 -0
  13. data/docs/multiple_commands.md +46 -0
  14. data/docs/reading_events.md +161 -0
  15. data/docs/writing_middleware.md +160 -0
  16. data/lib/pg_eventstore/abstract_command.rb +18 -0
  17. data/lib/pg_eventstore/client.rb +133 -0
  18. data/lib/pg_eventstore/commands/append.rb +61 -0
  19. data/lib/pg_eventstore/commands/multiple.rb +14 -0
  20. data/lib/pg_eventstore/commands/read.rb +24 -0
  21. data/lib/pg_eventstore/commands.rb +6 -0
  22. data/lib/pg_eventstore/config.rb +30 -0
  23. data/lib/pg_eventstore/connection.rb +97 -0
  24. data/lib/pg_eventstore/errors.rb +107 -0
  25. data/lib/pg_eventstore/event.rb +59 -0
  26. data/lib/pg_eventstore/event_class_resolver.rb +17 -0
  27. data/lib/pg_eventstore/event_serializer.rb +27 -0
  28. data/lib/pg_eventstore/extensions/options_extension.rb +103 -0
  29. data/lib/pg_eventstore/middleware.rb +15 -0
  30. data/lib/pg_eventstore/pg_result_deserializer.rb +48 -0
  31. data/lib/pg_eventstore/queries.rb +127 -0
  32. data/lib/pg_eventstore/query_builders/events_filtering_query.rb +187 -0
  33. data/lib/pg_eventstore/rspec/has_option_matcher.rb +90 -0
  34. data/lib/pg_eventstore/sql_builder.rb +126 -0
  35. data/lib/pg_eventstore/stream.rb +72 -0
  36. data/lib/pg_eventstore/tasks/setup.rake +37 -0
  37. data/lib/pg_eventstore/version.rb +5 -0
  38. data/lib/pg_eventstore.rb +85 -0
  39. data/pg_eventstore.gemspec +40 -0
  40. metadata +113 -0
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pg'
4
+ require 'pg/basic_type_map_for_results'
5
+ require 'pg/basic_type_map_for_queries'
6
+ require 'connection_pool'
7
+
8
+ module PgEventstore
9
+ class Connection
10
+ # Starting from ruby v3.1 ConnectionPool closes connections after forking by default. For ruby v3 we need this patch
11
+ # to correctly reload the ConnectionPool. Otherwise the same connection will leak into another process which will
12
+ # result in disaster.
13
+ # @!visibility private
14
+ module Ruby30Patch
15
+ def initialize(**)
16
+ @current_pid = Process.pid
17
+ @mutext = Mutex.new
18
+ super
19
+ end
20
+
21
+ def with(&blk)
22
+ reload_after_fork
23
+ super
24
+ end
25
+
26
+ private
27
+
28
+ def reload_after_fork
29
+ return if @current_pid == Process.pid
30
+
31
+ @mutext.synchronize do
32
+ return if @current_pid == Process.pid
33
+
34
+ @pool.reload(&:close)
35
+ @current_pid = Process.pid
36
+ end
37
+ end
38
+ end
39
+
40
+ attr_reader :uri, :pool_size, :pool_timeout
41
+ private :uri, :pool_size, :pool_timeout
42
+
43
+ # @param uri [String] PostgreSQL connection URI.
44
+ # Example: "postgresql://postgres:postgres@localhost:5432/eventstore"
45
+ # @param pool_size [Integer] Connection pool size
46
+ # @param pool_timeout [Integer] Connection pool timeout in seconds
47
+ def initialize(uri:, pool_size: 5, pool_timeout: 5)
48
+ @uri = uri
49
+ @pool_size = pool_size
50
+ @pool_timeout = pool_timeout
51
+ init_pool
52
+ end
53
+
54
+ # A shorthand from ConnectionPool#with.
55
+ # @yieldparam connection [PG::Connection] PostgreSQL connection instance
56
+ # @return [Object] a value of a given block
57
+ def with(&blk)
58
+ should_retry = true
59
+ @pool.with do |conn|
60
+ yield conn
61
+ rescue PG::ConnectionBad
62
+ # Recover connection after fork. We do it only once and without any delay. Recover is required by both
63
+ # processes - child process and parent process
64
+ conn.sync_reset
65
+ raise unless should_retry
66
+ should_retry = false
67
+ retry
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ # @return [ConnectionPool]
74
+ def init_pool
75
+ @pool ||= ConnectionPool.new(size: pool_size, timeout: pool_timeout) do
76
+ PG::Connection.new(uri).tap do |conn|
77
+ conn.type_map_for_results = PG::BasicTypeMapForResults.new(conn, registry: pg_type_registry)
78
+ conn.type_map_for_queries = PG::BasicTypeMapForQueries.new(conn, registry: pg_type_registry)
79
+ # conn.trace($stdout) # logs
80
+ end
81
+ end
82
+ end
83
+
84
+ # @return [PG::BasicTypeRegistry]
85
+ def pg_type_registry
86
+ registry = PG::BasicTypeRegistry.new.register_default_types
87
+ # 0 means that the pg value format is a text(1 for binary)
88
+ registry.alias_type(0, 'uuid', 'text')
89
+ registry.register_type 0, 'timestamp', PG::TextEncoder::TimestampUtc, PG::TextDecoder::TimestampUtc
90
+ registry
91
+ end
92
+ end
93
+ end
94
+
95
+ if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3.1')
96
+ PgEventstore::Connection.prepend(PgEventstore::Connection::Ruby30Patch)
97
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgEventstore
4
+ class Error < StandardError
5
+ # @return [Hash]
6
+ def as_json(*)
7
+ to_h.transform_keys(&:to_s)
8
+ end
9
+
10
+ # @return [Hash]
11
+ def to_h
12
+ hash =
13
+ instance_variables.each_with_object({}) do |var, result|
14
+ key = var.to_s
15
+ key[0] = '' # remove @ sign
16
+ result[key.to_sym] = instance_variable_get(var)
17
+ end
18
+ hash[:message] = message
19
+ hash[:backtrace] = backtrace
20
+ hash
21
+ end
22
+ end
23
+
24
+ class StreamNotFoundError < Error
25
+ attr_reader :stream
26
+
27
+ # @param stream [PgEventstore::Stream]
28
+ def initialize(stream)
29
+ @stream = stream
30
+ super("Stream #{stream.inspect} does not exist.")
31
+ end
32
+ end
33
+
34
+ class SystemStreamError < Error
35
+ attr_reader :stream
36
+
37
+ # @param stream [PgEventstore::Stream]
38
+ def initialize(stream)
39
+ @stream = stream
40
+ super("Stream #{stream.inspect} is a system stream and can't be used to append events.")
41
+ end
42
+ end
43
+
44
+ class WrongExpectedRevisionError < Error
45
+ attr_reader :revision, :expected_revision
46
+
47
+ # @param revision [Integer]
48
+ # @param expected_revision [Integer, Symbol]
49
+ def initialize(revision, expected_revision)
50
+ @revision = revision
51
+ @expected_revision = expected_revision
52
+ super(user_friendly_message)
53
+ end
54
+
55
+ private
56
+
57
+ # @return [String]
58
+ def user_friendly_message
59
+ return expected_stream_exists if revision == -1 && expected_revision == :stream_exists
60
+ return expected_no_stream if revision > -1 && expected_revision == :no_stream
61
+ return current_no_stream if revision == -1 && expected_revision.is_a?(Integer)
62
+
63
+ unmatched_stream_revision
64
+ end
65
+
66
+ # @return [String]
67
+ def expected_stream_exists
68
+ "Expected stream to exist, but it doesn't."
69
+ end
70
+
71
+ # @return [String]
72
+ def expected_no_stream
73
+ "Expected stream to be absent, but it actually exists."
74
+ end
75
+
76
+ # @return [String]
77
+ def current_no_stream
78
+ "Stream revision #{expected_revision} is expected, but stream does not exist."
79
+ end
80
+
81
+ # @return [String]
82
+ def unmatched_stream_revision
83
+ "Stream revision #{expected_revision} is expected, but actual stream revision is #{revision.inspect}."
84
+ end
85
+ end
86
+
87
+ class StreamDeletionError < Error
88
+ attr_reader :stream_name, :details
89
+
90
+ # @param stream_name [String]
91
+ # @param details [String]
92
+ def initialize(stream_name, details:)
93
+ @stream_name = stream_name
94
+ @details = details
95
+ super(user_friendly_message)
96
+ end
97
+
98
+ # @return [String]
99
+ def user_friendly_message
100
+ <<~TEXT.strip
101
+ Could not delete #{stream_name.inspect} stream. It seems that a stream with that \
102
+ name does not exist, has already been deleted or its state does not match the \
103
+ provided :expected_revision option. Please check #details for more info.
104
+ TEXT
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgEventstore
4
+ class Event
5
+ include Extensions::OptionsExtension
6
+
7
+ LINK_TYPE = '$>'
8
+
9
+ # @!attribute id
10
+ # @return [String] UUIDv4 string
11
+ attribute(:id)
12
+ # @!attribute type
13
+ # @return [String] event type
14
+ attribute(:type) { self.class.name }
15
+ # @!attribute global_position
16
+ # @return [Integer] event's position in "all" stream
17
+ attribute(:global_position)
18
+ # @!attribute stream
19
+ # @return [PgEventstore::Stream, nil] event's stream
20
+ attribute(:stream)
21
+ # @!attribute stream_revision
22
+ # @return [Integer] a revision of an event inside event's stream
23
+ attribute(:stream_revision)
24
+ # @!attribute data
25
+ # @return [Hash] event's data
26
+ attribute(:data) { {} }
27
+ # @!attribute metadata
28
+ # @return [Hash] event's metadata
29
+ attribute(:metadata) { {} }
30
+ # @!attribute link_id
31
+ # @return [String, nil] UUIDv4 of an event the current event points to. If it is not nil, then the current
32
+ # event is a link
33
+ attribute(:link_id)
34
+ # @!attribute created_at
35
+ # @return [Time, nil] a timestamp an event was created at
36
+ attribute(:created_at)
37
+
38
+ # Implements comparison of `PgEventstore::Event`-s. Two events matches if all of their attributes matches
39
+ # @param other [Object, EventStoreClient::DeserializedEvent]
40
+ # @return [Boolean]
41
+ def ==(other)
42
+ return false unless other.is_a?(PgEventstore::Event)
43
+
44
+ attributes_hash == other.attributes_hash
45
+ end
46
+
47
+ # Detect whether an event is a link event
48
+ # @return [Boolean]
49
+ def link?
50
+ !link_id.nil?
51
+ end
52
+
53
+ # Detect whether an event is a system event
54
+ # @return [Boolean]
55
+ def system?
56
+ type.start_with?('$')
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgEventstore
4
+ class EventClassResolver
5
+ # @param event_type [String]
6
+ # @return [Class]
7
+ def call(event_type)
8
+ Object.const_get(event_type)
9
+ rescue NameError, TypeError
10
+ PgEventstore.logger&.debug(<<~TEXT.strip)
11
+ Unable to resolve class by `#{event_type}' event type. \
12
+ Picking #{Event} event class to instantiate the event.
13
+ TEXT
14
+ Event
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgEventstore
4
+ # @!visibility private
5
+ class EventSerializer
6
+ attr_reader :middlewares
7
+
8
+ # @param middlewares [Array<#deserialize, #serialize>]
9
+ def initialize(middlewares)
10
+ @middlewares = middlewares
11
+ end
12
+
13
+ # @param event [PgEventstore::Event]
14
+ # @return [PgEventstore::Event]
15
+ def serialize(event)
16
+ @middlewares.each do |middleware|
17
+ middleware.serialize(event)
18
+ end
19
+ event
20
+ end
21
+
22
+ # @return [PgEventstore::EventSerializer]
23
+ def without_middlewares
24
+ self.class.new([])
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module PgEventstore
6
+ module Extensions
7
+ # A very simple extension that implements a DSL for adding attr_accessors with default values,
8
+ # and assigning their values during object initialization.
9
+ # Example. Let's say you frequently do something like this:
10
+ # ```ruby
11
+ # class SomeClass
12
+ # attr_accessor :attr1, :attr2, :attr3, :attr4
13
+ #
14
+ # def initialize(opts = {})
15
+ # @attr1 = opts[:attr1] || 'Attr 1 value'
16
+ # @attr2 = opts[:attr2] || 'Attr 2 value'
17
+ # @attr3 = opts[:attr3] || do_some_calc
18
+ # @attr4 = opts[:attr4]
19
+ # end
20
+ #
21
+ # def do_some_calc
22
+ # end
23
+ # end
24
+ #
25
+ # SomeClass.new(attr1: 'hihi', attr4: 'byebye')
26
+ # ```
27
+ #
28
+ # You can replace the code above using the OptionsExtension:
29
+ # ```ruby
30
+ # class SomeClass
31
+ # include PgEventstore::Extensions::OptionsExtension
32
+ #
33
+ # option(:attr1) { 'Attr 1 value' }
34
+ # option(:attr2) { 'Attr 2 value' }
35
+ # option(:attr3) { do_some_calc }
36
+ # option(:attr4)
37
+ # end
38
+ #
39
+ # SomeClass.new(attr1: 'hihi', attr4: 'byebye')
40
+ # ```
41
+ module OptionsExtension
42
+ module ClassMethods
43
+ # @param opt_name [Symbol] option name
44
+ # @param blk [Proc] provide define value using block. It will be later evaluated in the
45
+ # context of your object to determine the default value of the option
46
+ # @return [Symbol]
47
+ def option(opt_name, &blk)
48
+ self.options = (options + Set.new([opt_name])).freeze
49
+ warn_already_defined(opt_name)
50
+ warn_already_defined(:"#{opt_name}=")
51
+ attr_writer opt_name
52
+
53
+ define_method opt_name do
54
+ result = instance_variable_get(:"@#{opt_name}")
55
+ return result if instance_variable_defined?(:"@#{opt_name}")
56
+
57
+ instance_exec(&blk) if blk
58
+ end
59
+ end
60
+ alias attribute option
61
+
62
+ def inherited(klass)
63
+ super
64
+ klass.options = Set.new(options).freeze
65
+ end
66
+
67
+ private
68
+
69
+ # @param method_name [Symbol]
70
+ # @return [void]
71
+ def warn_already_defined(method_name)
72
+ return unless instance_methods.include?(method_name)
73
+
74
+ puts "Warning: Redefining already defined method #{self}##{method_name}"
75
+ end
76
+ end
77
+
78
+ def self.included(klass)
79
+ klass.singleton_class.attr_accessor(:options)
80
+ klass.options = Set.new.freeze
81
+ klass.extend(ClassMethods)
82
+ end
83
+
84
+ def initialize(**options)
85
+ self.class.options.each do |option|
86
+ # init default values of options
87
+ value = options.key?(option) ? options[option] : public_send(option)
88
+ public_send("#{option}=", value)
89
+ end
90
+ end
91
+
92
+ # Construct a hash from options, where key is the option's name and the value is option's
93
+ # value
94
+ # @return [Hash]
95
+ def options_hash
96
+ self.class.options.each_with_object({}) do |option, res|
97
+ res[option] = public_send(option)
98
+ end
99
+ end
100
+ alias attributes_hash options_hash
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgEventstore
4
+ module Middleware
5
+ # @param event [PgEventstore::Event]
6
+ # @return [void]
7
+ def serialize(event)
8
+ end
9
+
10
+ # @param event [PgEventstore::Event]
11
+ # @return [void]
12
+ def deserialize(event)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgEventstore
4
+ # @!visibility private
5
+ class PgResultDeserializer
6
+ attr_reader :middlewares, :event_class_resolver
7
+
8
+ # @param middlewares [Array<Object<#deserialize, #serialize>>]
9
+ # @param event_class_resolver [#call]
10
+ def initialize(middlewares, event_class_resolver)
11
+ @middlewares = middlewares
12
+ @event_class_resolver = event_class_resolver
13
+ end
14
+
15
+ # @param pg_result [PG::Result]
16
+ # @return [Array<PgEventstore::Event>]
17
+ def deserialize(pg_result)
18
+ pg_result.map(&method(:_deserialize))
19
+ end
20
+ alias deserialize_many deserialize
21
+
22
+ # @param pg_result [PG::Result]
23
+ # @return [PgEventstore::Event, nil]
24
+ def deserialize_one(pg_result)
25
+ return if pg_result.ntuples.zero?
26
+
27
+ _deserialize(pg_result.first)
28
+ end
29
+
30
+ # @return [PgEventstore::PgResultDeserializer]
31
+ def without_middlewares
32
+ self.class.new([], event_class_resolver)
33
+ end
34
+
35
+ private
36
+
37
+ # @param attrs [Hash]
38
+ # @return [PgEventstore::Event]
39
+ def _deserialize(attrs)
40
+ event = event_class_resolver.call(attrs['type']).new(**attrs.transform_keys(&:to_sym))
41
+ middlewares.each do |middleware|
42
+ middleware.deserialize(event)
43
+ end
44
+ event.stream = PgEventstore::Stream.new(**attrs['stream'].transform_keys(&:to_sym)) if attrs.key?('stream')
45
+ event
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'query_builders/events_filtering_query'
4
+
5
+ module PgEventstore
6
+ # @!visibility private
7
+ class Queries
8
+ attr_reader :connection, :serializer, :deserializer
9
+ private :connection, :serializer, :deserializer
10
+
11
+ # @param connection [PgEventstore::Connection]
12
+ # @param serializer [PgEventstore::EventSerializer]
13
+ # @param deserializer [PgEventstore::PgResultDeserializer]
14
+ def initialize(connection, serializer, deserializer)
15
+ @connection = connection
16
+ @serializer = serializer
17
+ @deserializer = deserializer
18
+ end
19
+
20
+ # @return [void]
21
+ def transaction
22
+ connection.with do |conn|
23
+ # We are inside a transaction already - no need to start another one
24
+ if [PG::PQTRANS_ACTIVE, PG::PQTRANS_INTRANS].include?(conn.transaction_status)
25
+ next yield
26
+ end
27
+
28
+ conn.transaction do
29
+ conn.exec("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")
30
+ yield
31
+ end
32
+ end
33
+ rescue PG::TRSerializationFailure, PG::TRDeadlockDetected => e
34
+ retry if e.connection.transaction_status == PG::PQTRANS_IDLE
35
+ raise
36
+ end
37
+
38
+ # Finds a stream in the database by the given Stream object
39
+ # @param stream [PgEventstore::Stream]
40
+ # @return [PgEventstore::Stream, nil] persisted stream
41
+ def find_stream(stream)
42
+ builder =
43
+ SQLBuilder.new.
44
+ from('streams').
45
+ where('streams.context = ? AND streams.stream_name = ? AND streams.stream_id = ?', *stream.to_a).
46
+ limit(1)
47
+ pg_result = connection.with do |conn|
48
+ conn.exec_params(*builder.to_exec_params)
49
+ end
50
+ PgEventstore::Stream.new(**pg_result.to_a.first.transform_keys(&:to_sym)) if pg_result.ntuples == 1
51
+ end
52
+
53
+ # @param stream [PgEventstore::Stream]
54
+ # @return [PgEventstore::RawStream] persisted stream
55
+ def create_stream(stream)
56
+ create_sql = <<~SQL
57
+ INSERT INTO streams (context, stream_name, stream_id) VALUES ($1, $2, $3) RETURNING *
58
+ SQL
59
+ pg_result = connection.with do |conn|
60
+ conn.exec_params(create_sql, stream.to_a)
61
+ end
62
+ PgEventstore::Stream.new(**pg_result.to_a.first.transform_keys(&:to_sym))
63
+ end
64
+
65
+ # @return [PgEventstore::Stream] persisted stream
66
+ def find_or_create_stream(stream)
67
+ find_stream(stream) || create_stream(stream)
68
+ end
69
+
70
+ # @see PgEventstore::Client#read for more info
71
+ # @param stream [PgEventstore::Stream]
72
+ # @param options [Hash]
73
+ # @return [Array<PgEventstore::Event>]
74
+ def stream_events(stream, options)
75
+ exec_params = events_filtering(stream, options).to_exec_params
76
+ pg_result = connection.with do |conn|
77
+ conn.exec_params(*exec_params)
78
+ end
79
+ deserializer.deserialize_many(pg_result)
80
+ end
81
+
82
+ # @param stream [PgEventstore::Stream] persisted stream
83
+ # @param event [PgEventstore::Event]
84
+ # @return [PgEventstore::Event]
85
+ def insert(stream, event)
86
+ serializer.serialize(event)
87
+
88
+ attributes = event.options_hash.slice(:id, :type, :data, :metadata, :stream_revision, :link_id).compact
89
+ attributes[:stream_id] = stream.id
90
+
91
+ sql = <<~SQL
92
+ INSERT INTO events (#{attributes.keys.join(', ')})
93
+ VALUES (#{(1..attributes.values.size).map { |n| "$#{n}" }.join(', ')})
94
+ RETURNING *
95
+ SQL
96
+
97
+ pg_result = connection.with do |conn|
98
+ conn.exec_params(sql, attributes.values)
99
+ end
100
+ deserializer.without_middlewares.deserialize_one(pg_result).tap do |persisted_event|
101
+ persisted_event.stream = stream
102
+ end
103
+ end
104
+
105
+ # @param stream [PgEventstore::Stream] persisted stream
106
+ # @return [void]
107
+ def update_stream_revision(stream, revision)
108
+ connection.with do |conn|
109
+ conn.exec_params(<<~SQL, [revision, stream.id])
110
+ UPDATE streams SET stream_revision = $1 WHERE id = $2
111
+ SQL
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ # @param stream [PgEventstore::Stream]
118
+ # @param options [Hash]
119
+ # @param offset [Integer]
120
+ # @return [PgEventstore::EventsFilteringQuery]
121
+ def events_filtering(stream, options, offset: 0)
122
+ return QueryBuilders::EventsFiltering.all_stream_filtering(options, offset: offset) if stream.all_stream?
123
+
124
+ QueryBuilders::EventsFiltering.specific_stream_filtering(stream, options, offset: offset)
125
+ end
126
+ end
127
+ end