ruby-object-framework 1.0.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 (55) hide show
  1. checksums.yaml +7 -0
  2. data/API.md +78 -0
  3. data/README.md +250 -0
  4. data/THIRD_PARTY_NOTICES.md +45 -0
  5. data/examples/guides/01_plain_use_case.rb +13 -0
  6. data/examples/guides/02_composed_application.rb +25 -0
  7. data/examples/guides/03_order_domain.rb +14 -0
  8. data/examples/guides/04_scoped_resources.rb +38 -0
  9. data/examples/guides/05_mapping.rb +39 -0
  10. data/examples/guides/06_associations.rb +49 -0
  11. data/examples/guides/07_http.rb +17 -0
  12. data/examples/guides/08_errors.rb +20 -0
  13. data/examples/guides/09_sqlite.rb +38 -0
  14. data/examples/guides/10_grpc.rb +26 -0
  15. data/examples/guides/11_ractor.rb +15 -0
  16. data/examples/guides/12_box.rb +11 -0
  17. data/examples/guides/13_testing.rb +24 -0
  18. data/examples/guides/manifest.yml +62 -0
  19. data/examples/orders/application.rb +51 -0
  20. data/examples/orders/contracts.rb +19 -0
  21. data/examples/orders/domain.rb +35 -0
  22. data/examples/orders/export_proto.rb +5 -0
  23. data/examples/orders/grpc_client.py +24 -0
  24. data/examples/orders/grpc_server.rb +23 -0
  25. data/examples/orders/server.rb +18 -0
  26. data/lib/object_framework/application.rb +136 -0
  27. data/lib/object_framework/capabilities.rb +59 -0
  28. data/lib/object_framework/errors.rb +85 -0
  29. data/lib/object_framework/execution.rb +184 -0
  30. data/lib/object_framework/grpc/messages.rb +30 -0
  31. data/lib/object_framework/grpc/server.rb +264 -0
  32. data/lib/object_framework/grpc/service.rb +55 -0
  33. data/lib/object_framework/http/io.rb +127 -0
  34. data/lib/object_framework/http/presentation.rb +55 -0
  35. data/lib/object_framework/http/protocol.rb +209 -0
  36. data/lib/object_framework/http/router.rb +80 -0
  37. data/lib/object_framework/http/server.rb +279 -0
  38. data/lib/object_framework/http2/connection.rb +399 -0
  39. data/lib/object_framework/http2/frame.rb +21 -0
  40. data/lib/object_framework/http2/hpack.rb +206 -0
  41. data/lib/object_framework/http2/hpack_tables.rb +333 -0
  42. data/lib/object_framework/http2/inbound.rb +75 -0
  43. data/lib/object_framework/memory/storage.rb +97 -0
  44. data/lib/object_framework/persistence/mapping.rb +150 -0
  45. data/lib/object_framework/persistence/query.rb +97 -0
  46. data/lib/object_framework/persistence/session.rb +173 -0
  47. data/lib/object_framework/persistence/transaction.rb +54 -0
  48. data/lib/object_framework/protobuf.rb +268 -0
  49. data/lib/object_framework/sqlite/compiler.rb +77 -0
  50. data/lib/object_framework/sqlite/schema.rb +95 -0
  51. data/lib/object_framework/sqlite/storage.rb +220 -0
  52. data/lib/object_framework/sqlite.rb +6 -0
  53. data/lib/object_framework/version.rb +5 -0
  54. data/lib/object_framework.rb +31 -0
  55. metadata +105 -0
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ObjectFramework
4
+ module SQLite
5
+ ForeignKey = Data.define(:table, :column, :target)
6
+ Migration = Data.define(:version, :actions)
7
+
8
+ class Schema
9
+ TYPES = { string: "TEXT", integer: "INTEGER", float: "REAL", boolean: "INTEGER", blob: "BLOB" }.freeze
10
+ attr_reader :tables, :foreign_keys, :migrations
11
+
12
+ def self.from_mappings(mappings)
13
+ mappings.validate!
14
+ keys = mappings.flat_map do |mapping|
15
+ mapping.associations.select { |item| item.kind == :belongs_to }.map do |association|
16
+ ForeignKey.new(table: mapping.table, column: association.foreign_key, target: mappings.fetch(association.target).table)
17
+ end
18
+ end
19
+ new(tables: mappings.to_h { |mapping| [mapping.table, mapping.schema] }, foreign_keys: keys)
20
+ end
21
+
22
+ def initialize(tables:, foreign_keys: [])
23
+ @tables = tables.to_h do |name, columns|
24
+ fields = columns.to_h { |column, type| [identifier(column), validate_type(type)] }
25
+ raise ConfigurationError, "SQLite tables require a string id" unless fields[:id] == :string
26
+ [identifier(name), fields]
27
+ end
28
+ @foreign_keys = foreign_keys.dup.freeze
29
+ @initial_tables = @tables.transform_values { |columns| columns.dup.freeze }.freeze
30
+ @migrations = [Migration.new(version: 1, actions: [].freeze)]
31
+ end
32
+
33
+ def migration(version, &declaration)
34
+ raise ConfigurationError, "Schema is finalized" if frozen?
35
+ raise ConfigurationError, "Migration versions must be consecutive" unless version == @migrations.last.version + 1
36
+ editor = Editor.new(self)
37
+ editor.instance_eval(&declaration)
38
+ @migrations << Migration.new(version:, actions: editor.actions.each(&:freeze).freeze)
39
+ self
40
+ end
41
+
42
+ def version; @migrations.last.version; end
43
+
44
+ def table(name)
45
+ @tables.fetch(identifier(name)) { raise ConfigurationError, "Unregistered SQLite table" }
46
+ end
47
+
48
+ def type(table_name, column)
49
+ table(table_name).fetch(identifier(column)) { raise ConfigurationError, "Unregistered SQLite column" }
50
+ end
51
+
52
+ def quote(name); '"' + identifier(name).to_s + '"'; end
53
+ def identifier(name); Persistence::Query.identifier(name); end
54
+
55
+ def validate_type(type)
56
+ raise ConfigurationError, "Unknown SQLite field type" unless TYPES.key?(type)
57
+ type
58
+ end
59
+
60
+ def finalize!
61
+ return self if frozen?
62
+ foreign_keys.each { |key| type(key.table, key.column); type(key.target, :id) }
63
+ @tables.each_value(&:freeze)
64
+ @tables.freeze
65
+ @migrations.freeze
66
+ freeze
67
+ end
68
+
69
+ def create_statements
70
+ @initial_tables.map do |name, columns|
71
+ fields = columns.map do |column, type|
72
+ "#{quote(column)} #{TYPES.fetch(type)}#{column == :id ? ' PRIMARY KEY NOT NULL' : ''}"
73
+ end
74
+ foreign_keys.select { |key| key.table == name }.each do |key|
75
+ fields << "FOREIGN KEY (#{quote(key.column)}) REFERENCES #{quote(key.target)} (\"id\") DEFERRABLE INITIALLY DEFERRED"
76
+ end
77
+ "CREATE TABLE #{quote(name)} (#{fields.join(', ')}) STRICT"
78
+ end
79
+ end
80
+
81
+ class Editor
82
+ attr_reader :actions
83
+ def initialize(schema); @schema, @actions = schema, []; end
84
+
85
+ def add_column(table, column, type: :string)
86
+ table, column = @schema.identifier(table), @schema.identifier(column)
87
+ fields = @schema.table(table)
88
+ raise ConfigurationError, "Column already exists" if fields.key?(column)
89
+ fields[column] = @schema.validate_type(type)
90
+ @actions << "ALTER TABLE #{@schema.quote(table)} ADD COLUMN #{@schema.quote(column)} #{TYPES.fetch(type)}"
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ObjectFramework
4
+ module SQLite
5
+ module ErrorTranslation
6
+ def self.raise_translated(error)
7
+ code = error.code
8
+ type = case code
9
+ when 1555, 2067 then Duplicate
10
+ when 517 then Conflict
11
+ when 787, 1299, 275, 3091 then InvalidInput
12
+ else
13
+ case code & 255
14
+ when 5, 6, 10, 13, 14 then Unavailable
15
+ when 19, 20, 18 then InvalidInput
16
+ else nil
17
+ end
18
+ end
19
+ raise(type.new("SQLite operation failed"), cause: error) if type
20
+ raise error
21
+ end
22
+ end
23
+
24
+ class Storage
25
+ attr_reader :schema_version
26
+
27
+ def initialize(path:, schema:, lock_timeout: 1)
28
+ raise ConfigurationError, "SQLite requires a database file path" if path.to_s.empty? || path.to_s == ":memory:"
29
+ raise ConfigurationError, "Lock timeout must be positive" unless lock_timeout.is_a?(Numeric) && lock_timeout.positive?
30
+ @path, @schema, @lock_timeout = File.expand_path(path), schema.finalize!, lock_timeout
31
+ @mutex, @connections, @closed = Mutex.new, 0, false
32
+ migrate
33
+ end
34
+
35
+ def open_connection_count; @mutex.synchronize { @connections }; end
36
+
37
+ def begin_transaction(mode:)
38
+ begin_transaction_for(mode:, context: ExecutionContext.new)
39
+ end
40
+
41
+ def begin_transaction_for(mode:, context:)
42
+ @mutex.synchronize { raise Unavailable, "Storage is closed" if @closed }
43
+ context.cancellation.check!
44
+ database = connect(cancellation: context.cancellation)
45
+ Transaction.new(database:, schema: @schema, mode:, cancellation: context.cancellation,
46
+ closed: -> { disconnected })
47
+ rescue Exception
48
+ if database && !database.closed?
49
+ database.close
50
+ disconnected
51
+ end
52
+ raise
53
+ end
54
+
55
+ def close
56
+ @mutex.synchronize { @closed = true }
57
+ end
58
+
59
+ private
60
+
61
+ def connect(cancellation: Cancellation.new)
62
+ database = ::SQLite3::Database.new(@path, results_as_hash: true)
63
+ @mutex.synchronize { @connections += 1 }
64
+ database.extended_result_codes = true
65
+ lock_started = nil
66
+ database.busy_handler do |count|
67
+ cancellation.check!
68
+ lock_started = monotonic if count.zero? || lock_started.nil?
69
+ remaining = @lock_timeout - (monotonic - lock_started)
70
+ if remaining.positive?
71
+ sleep [remaining, 0.005].min
72
+ true
73
+ else
74
+ false
75
+ end
76
+ end
77
+ database.execute("PRAGMA foreign_keys = ON")
78
+ database.execute("PRAGMA synchronous = FULL")
79
+ database
80
+ rescue Exception => error
81
+ if database
82
+ database.close unless database.closed?
83
+ disconnected
84
+ end
85
+ ErrorTranslation.raise_translated(error) if error.is_a?(::SQLite3::Exception)
86
+ raise
87
+ end
88
+
89
+ def disconnected
90
+ @mutex.synchronize { @connections -= 1 }
91
+ end
92
+
93
+ def migrate
94
+ database = connect
95
+ database.execute("PRAGMA journal_mode = DELETE")
96
+ database.execute("BEGIN IMMEDIATE")
97
+ current = database.get_first_value("PRAGMA user_version")
98
+ raise ConfigurationError, "Database schema is newer than the application" if current > @schema.version
99
+ @schema.migrations.each do |migration|
100
+ next if migration.version <= current
101
+ statements = migration.version == 1 ? @schema.create_statements : migration.actions
102
+ statements.each { |statement| database.execute(statement) }
103
+ database.execute("PRAGMA user_version = #{migration.version}")
104
+ end
105
+ validate_schema(database)
106
+ database.execute("COMMIT")
107
+ @schema_version = @schema.version
108
+ rescue ::SQLite3::Exception => error
109
+ ErrorTranslation.raise_translated(error)
110
+ ensure
111
+ if database
112
+ database.close
113
+ disconnected
114
+ end
115
+ end
116
+
117
+ def monotonic
118
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
119
+ end
120
+
121
+ def validate_schema(database)
122
+ @schema.tables.each do |name, columns|
123
+ actual = database.execute("PRAGMA table_info(#{@schema.quote(name)})").to_h { |row| [row.fetch("name"), row.fetch("type")] }
124
+ expected = columns.to_h { |column, type| [column.to_s, Schema::TYPES.fetch(type)] }
125
+ raise ConfigurationError, "SQLite schema does not match its declared version" unless actual == expected
126
+ end
127
+ end
128
+ end
129
+
130
+ class Transaction < Persistence::Transaction
131
+ def initialize(database:, schema:, mode:, cancellation:, closed:)
132
+ super(mode:)
133
+ @database, @schema, @cancellation, @closed_callback = database, schema, cancellation, closed
134
+ @compiler = Compiler.new(schema:)
135
+ execute(mode == :write ? "BEGIN IMMEDIATE" : "BEGIN DEFERRED")
136
+ @database.execute("PRAGMA query_only = ON") if mode == :read
137
+ end
138
+
139
+ def configuration
140
+ active!
141
+ %i[foreign_keys journal_mode synchronous].to_h { |name| [name, @database.get_first_value("PRAGMA #{name}")] }
142
+ end
143
+
144
+ def find(table, id)
145
+ query(Persistence::Query.new(table:).where(id:).limit(1)).first
146
+ end
147
+
148
+ def query(query)
149
+ active!
150
+ sql, bindings = @compiler.query(query)
151
+ execute(sql, bindings).map { |row| @compiler.decode(query.table, row) }
152
+ end
153
+
154
+ def insert(table, row)
155
+ writable!
156
+ value = row_value(row)
157
+ @schema.table(table)
158
+ columns = value.keys
159
+ bindings = columns.map { |column| @compiler.encode(table, column, value[column]) }
160
+ execute("INSERT INTO #{@schema.quote(table)} (#{columns.map { |column| @schema.quote(column) }.join(', ')}) VALUES (#{(['?'] * columns.size).join(', ')})", bindings)
161
+ value[:id]
162
+ end
163
+
164
+ def update(table, id, row)
165
+ writable!
166
+ value = row_value(row, id:)
167
+ @schema.table(table)
168
+ columns = @schema.table(table).keys - [:id]
169
+ if columns.empty?
170
+ raise NotFound, "Unknown identity" unless find(table, id)
171
+ return
172
+ end
173
+ values = columns.map { |column| @compiler.encode(table, column, value[column]) }
174
+ sql = "UPDATE #{@schema.quote(table)} SET #{columns.map { |column| "#{@schema.quote(column)} = ?" }.join(', ')} WHERE \"id\" = ?"
175
+ execute(sql, values + [id])
176
+ raise NotFound, "Unknown identity" if @database.changes.zero?
177
+ end
178
+
179
+ def delete(table, id)
180
+ writable!
181
+ @schema.table(table)
182
+ execute("DELETE FROM #{@schema.quote(table)} WHERE \"id\" = ?", [id])
183
+ raise NotFound, "Unknown identity" if @database.changes.zero?
184
+ end
185
+
186
+ def commit
187
+ active!
188
+ begin
189
+ execute("COMMIT")
190
+ @state = :committed
191
+ rescue StandardError
192
+ @state = :indeterminate unless @database.transaction_active?
193
+ raise
194
+ end
195
+ end
196
+
197
+ private
198
+
199
+ def execute(sql, bindings = [])
200
+ @cancellation.check!
201
+ @database.execute(sql, bindings)
202
+ rescue ::SQLite3::Exception => error
203
+ ErrorTranslation.raise_translated(error)
204
+ end
205
+
206
+ def rollback_storage
207
+ # Cleanup must remain possible after deadline expiry or cancellation.
208
+ @database.execute("ROLLBACK")
209
+ rescue ::SQLite3::Exception => error
210
+ ErrorTranslation.raise_translated(error)
211
+ end
212
+
213
+ def close_storage
214
+ @database.close
215
+ ensure
216
+ @closed_callback.call
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite3"
4
+ require_relative "sqlite/schema"
5
+ require_relative "sqlite/compiler"
6
+ require_relative "sqlite/storage"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ObjectFramework
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "thread"
5
+ require_relative "object_framework/version"
6
+ require_relative "object_framework/errors"
7
+ require_relative "object_framework/execution"
8
+ require_relative "object_framework/application"
9
+ require_relative "object_framework/persistence/query"
10
+ require_relative "object_framework/persistence/transaction"
11
+ require_relative "object_framework/persistence/mapping"
12
+ require_relative "object_framework/persistence/session"
13
+ require_relative "object_framework/memory/storage"
14
+ require_relative "object_framework/http/protocol"
15
+ require_relative "object_framework/http/router"
16
+ require_relative "object_framework/http/presentation"
17
+ require_relative "object_framework/http/io"
18
+ require_relative "object_framework/http/server"
19
+ require_relative "object_framework/protobuf"
20
+ require_relative "object_framework/grpc/service"
21
+ require_relative "object_framework/http2/hpack"
22
+ require_relative "object_framework/http2/frame"
23
+ require_relative "object_framework/http2/inbound"
24
+ require_relative "object_framework/grpc/messages"
25
+ require_relative "object_framework/http2/connection"
26
+ require_relative "object_framework/grpc/server"
27
+
28
+ module ObjectFramework
29
+ autoload :SQLite, File.expand_path("object_framework/sqlite", __dir__)
30
+ autoload :Capabilities, File.expand_path("object_framework/capabilities", __dir__)
31
+ end
metadata ADDED
@@ -0,0 +1,105 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby-object-framework
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Gastón Caruso
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Explicit application composition, scoped execution, Data Mapper persistence,
13
+ HTTP and native gRPC, with an optional SQLite adapter.
14
+ email:
15
+ - gstn.caruso@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files:
19
+ - API.md
20
+ - README.md
21
+ files:
22
+ - API.md
23
+ - README.md
24
+ - THIRD_PARTY_NOTICES.md
25
+ - examples/guides/01_plain_use_case.rb
26
+ - examples/guides/02_composed_application.rb
27
+ - examples/guides/03_order_domain.rb
28
+ - examples/guides/04_scoped_resources.rb
29
+ - examples/guides/05_mapping.rb
30
+ - examples/guides/06_associations.rb
31
+ - examples/guides/07_http.rb
32
+ - examples/guides/08_errors.rb
33
+ - examples/guides/09_sqlite.rb
34
+ - examples/guides/10_grpc.rb
35
+ - examples/guides/11_ractor.rb
36
+ - examples/guides/12_box.rb
37
+ - examples/guides/13_testing.rb
38
+ - examples/guides/manifest.yml
39
+ - examples/orders/application.rb
40
+ - examples/orders/contracts.rb
41
+ - examples/orders/domain.rb
42
+ - examples/orders/export_proto.rb
43
+ - examples/orders/grpc_client.py
44
+ - examples/orders/grpc_server.rb
45
+ - examples/orders/server.rb
46
+ - lib/object_framework.rb
47
+ - lib/object_framework/application.rb
48
+ - lib/object_framework/capabilities.rb
49
+ - lib/object_framework/errors.rb
50
+ - lib/object_framework/execution.rb
51
+ - lib/object_framework/grpc/messages.rb
52
+ - lib/object_framework/grpc/server.rb
53
+ - lib/object_framework/grpc/service.rb
54
+ - lib/object_framework/http/io.rb
55
+ - lib/object_framework/http/presentation.rb
56
+ - lib/object_framework/http/protocol.rb
57
+ - lib/object_framework/http/router.rb
58
+ - lib/object_framework/http/server.rb
59
+ - lib/object_framework/http2/connection.rb
60
+ - lib/object_framework/http2/frame.rb
61
+ - lib/object_framework/http2/hpack.rb
62
+ - lib/object_framework/http2/hpack_tables.rb
63
+ - lib/object_framework/http2/inbound.rb
64
+ - lib/object_framework/memory/storage.rb
65
+ - lib/object_framework/persistence/mapping.rb
66
+ - lib/object_framework/persistence/query.rb
67
+ - lib/object_framework/persistence/session.rb
68
+ - lib/object_framework/persistence/transaction.rb
69
+ - lib/object_framework/protobuf.rb
70
+ - lib/object_framework/sqlite.rb
71
+ - lib/object_framework/sqlite/compiler.rb
72
+ - lib/object_framework/sqlite/schema.rb
73
+ - lib/object_framework/sqlite/storage.rb
74
+ - lib/object_framework/version.rb
75
+ homepage: https://github.com/gstn-caruso/ruby-object-framework
76
+ licenses:
77
+ - Nonstandard
78
+ metadata:
79
+ source_code_uri: https://github.com/gstn-caruso/ruby-object-framework
80
+ documentation_uri: https://gstn-caruso.github.io/ruby-object-framework/
81
+ changelog_uri: https://github.com/gstn-caruso/ruby-object-framework/releases
82
+ allowed_push_host: https://rubygems.org
83
+ rubygems_mfa_required: 'true'
84
+ rdoc_options:
85
+ - "--main"
86
+ - API.md
87
+ - "--visibility"
88
+ - public
89
+ require_paths:
90
+ - lib
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: 4.0.8
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ requirements: []
102
+ rubygems_version: 4.0.20
103
+ specification_version: 4
104
+ summary: A modular Ruby framework built from collaborating objects
105
+ test_files: []