pg_eventstore 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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