ruflet_record 0.0.1

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4ff4208c2b7a87dc5551191990f98dfcae70e0fb269eb3a2dbb17f15c37486ee
4
+ data.tar.gz: 97605cab4d6d1ed79775b33ec3628f1264c857b8be05cc69bd8e95d8e870adb5
5
+ SHA512:
6
+ metadata.gz: b654171b28b24b46383be6fcbc5194271f6ee573413cb973c3d6dd9842e9c6e18bc84f3a82d8f1b2fbd000375562b2e3904ea5cded0a3f11a37b05527ebe44ea
7
+ data.tar.gz: 2e6e410915adb49b2a8d06b66e6e064bb8676c28473a2301cafa35142e1c94140dfdff6b75bbc3b83549ae099a2289937a071466bd6aa4b9bda9da56dfc18639
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ruflet contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,173 @@
1
+ # RufletRecord
2
+
3
+ RufletRecord is a small, lazy SQLite ORM for Ruflet applications. Its API is
4
+ shaped like the useful core of Active Record, but it has no Rails or
5
+ ActiveSupport dependency and is compiled directly into Ruflet's mruby VM.
6
+
7
+ The gem runs on CRuby with the `sqlite3` gem and on Ruflet's mruby runtime with
8
+ the bundled native SQLite 3.53.4 bridge.
9
+
10
+ ## Setup
11
+
12
+ ```ruby
13
+ require "ruflet_record"
14
+
15
+ RufletRecord.establish_connection(
16
+ database: File.join(Dir.pwd, "storage", "app.sqlite3"),
17
+ journal_mode: :wal,
18
+ timeout: 5_000
19
+ )
20
+ ```
21
+
22
+ `foreign_keys` defaults to `true`. Use `database: ":memory:"` in tests.
23
+
24
+ ## Schema
25
+
26
+ ```ruby
27
+ RufletRecord::Schema.define do
28
+ create_table :users, if_not_exists: true do |table|
29
+ table.string :name, null: false
30
+ table.string :email
31
+ table.boolean :active, default: true
32
+ table.timestamps
33
+ table.index :email, unique: true
34
+ end
35
+
36
+ create_table :posts, if_not_exists: true do |table|
37
+ table.references :user, null: false, foreign_key: { on_delete: :cascade }
38
+ table.string :title, null: false
39
+ table.text :body
40
+ table.timestamps
41
+ end
42
+ end
43
+ ```
44
+
45
+ Supported column helpers are `string`, `text`, `integer`, `float`, `decimal`,
46
+ `boolean`, `datetime`, `date`, `binary`, `json`, `references`, and
47
+ `timestamps`. Schema operations include `create_table`, `drop_table`,
48
+ `add_column`, `add_index`, `remove_index`, and `rename_table`.
49
+
50
+ `references` creates an index by default. Pass `index: false` to omit it and
51
+ `foreign_key: true` to reference the conventionally named table. A hash can
52
+ configure `to_table`, `primary_key`, `on_delete`, and `on_update`; supported
53
+ actions are `cascade`, `restrict`, `set_null`, `set_default`, and `no_action`.
54
+
55
+ For application migration classes:
56
+
57
+ ```ruby
58
+ class CreateTasks < RufletRecord::Migration
59
+ def change
60
+ create_table :tasks do |table|
61
+ table.string :title, null: false
62
+ table.boolean :done, default: false
63
+ table.timestamps
64
+ end
65
+ end
66
+ end
67
+
68
+ CreateTasks.migrate(:up)
69
+ ```
70
+
71
+ ## Models
72
+
73
+ ```ruby
74
+ class User < RufletRecord::Base
75
+ validates_presence_of :name
76
+ validates_uniqueness_of :email
77
+
78
+ has_many :posts
79
+ has_one :profile
80
+
81
+ scope :active, -> { where(active: true) }
82
+ end
83
+
84
+ class Post < RufletRecord::Base
85
+ belongs_to :user
86
+ end
87
+ ```
88
+
89
+ Table names are inferred (`Post` → `posts`). Override conventions where
90
+ needed:
91
+
92
+ ```ruby
93
+ class LegacyEntry < RufletRecord::Base
94
+ self.table_name = "entries"
95
+ self.primary_key = "entry_id"
96
+ end
97
+ ```
98
+
99
+ ## Persistence
100
+
101
+ ```ruby
102
+ user = User.create!(name: "Ada", email: "ada@example.com")
103
+ post = user.posts.create!(title: "Notes")
104
+
105
+ user.update!(active: false)
106
+ user.reload
107
+ user.destroy
108
+ ```
109
+
110
+ The common persistence API includes `new`, `create`, `create!`, `save`,
111
+ `save!`, `update`, `update!`, `update_columns`, `touch`, `destroy`, `delete`,
112
+ `reload`, `find_or_initialize_by`, `find_or_create_by`, and
113
+ `find_or_create_by!`.
114
+
115
+ ## Lazy queries
116
+
117
+ Building a relation never touches SQLite:
118
+
119
+ ```ruby
120
+ query = User.where(active: true).order(name: :asc).limit(20)
121
+ # No SQL has run yet.
122
+
123
+ users = query.to_a # SQL runs here.
124
+ ```
125
+
126
+ These methods only build immutable relation objects:
127
+
128
+ - `where` and `where.not`
129
+ - `order` and `reorder`
130
+ - `select` and `distinct`
131
+ - `limit` and `offset`
132
+ - `joins`
133
+ - model scopes and association readers
134
+
135
+ SQL executes when records or scalar values are requested with `each`, `to_a`,
136
+ `first`, `last`, `find`, `find_by`, `pluck`, `pick`, `ids`, `count`, `sum`,
137
+ `average`, `minimum`, `maximum`, or `exists?`. Writes execute immediately.
138
+
139
+ ```ruby
140
+ User.where("age >= ?", 18)
141
+ User.where(id: [1, 2, 3])
142
+ User.where(created_at: start_time..end_time)
143
+ User.where.not(active: false)
144
+ User.order(age: :desc).pluck(:name, :age)
145
+ ```
146
+
147
+ Values use SQLite bind parameters. Identifiers are quoted. Raw ordering is
148
+ strictly validated; use `RufletRecord.sql(...)` only for trusted application
149
+ SQL:
150
+
151
+ ```ruby
152
+ User.select(RufletRecord.sql("COUNT(*) AS total"))
153
+ ```
154
+
155
+ ## Transactions
156
+
157
+ ```ruby
158
+ User.transaction do
159
+ user = User.create!(name: "Grace")
160
+ user.posts.create!(title: "Compiler notes")
161
+ end
162
+ ```
163
+
164
+ Nested transactions use SQLite savepoints.
165
+
166
+ ## Deliberate limits
167
+
168
+ RufletRecord is not Rails Active Record. It deliberately omits callbacks,
169
+ STI, eager loading, polymorphic associations, database portability, and Arel.
170
+ Hard invariants should use SQLite constraints and unique indexes; the included
171
+ presence and uniqueness validations exist for user-facing errors.
172
+
173
+ For uncommon SQL, use `RufletRecord.connection.execute(sql, binds)` directly.
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RufletRecord
4
+ module Adapters
5
+ class SQLiteAdapter
6
+ attr_reader :database
7
+
8
+ def initialize(config)
9
+ @config = normalize_config(config)
10
+ @database = @config[:database]
11
+ @driver = build_driver(@database)
12
+ @transaction_depth = 0
13
+ configure
14
+ end
15
+
16
+ def execute(sql, binds = [])
17
+ @driver.execute(sql, normalize_binds(binds))
18
+ rescue StandardError => error
19
+ raise StatementInvalid, error.message
20
+ end
21
+
22
+ def execute_batch(sql)
23
+ @driver.execute_batch(sql)
24
+ rescue StandardError => error
25
+ raise StatementInvalid, error.message
26
+ end
27
+
28
+ def select_all(sql, binds = [])
29
+ execute(sql, binds)
30
+ end
31
+
32
+ def select_one(sql, binds = [])
33
+ select_all(sql, binds).first
34
+ end
35
+
36
+ def select_value(sql, binds = [])
37
+ row = select_one(sql, binds)
38
+ row && row.values.first
39
+ end
40
+
41
+ def insert(sql, binds = [])
42
+ execute(sql, binds)
43
+ @driver.last_insert_row_id
44
+ end
45
+
46
+ def update(sql, binds = [])
47
+ execute(sql, binds)
48
+ @driver.changes
49
+ end
50
+ alias delete update
51
+
52
+ def transaction
53
+ savepoint = "ruflet_record_#{@transaction_depth}"
54
+ if @transaction_depth.zero?
55
+ execute("BEGIN IMMEDIATE")
56
+ else
57
+ execute("SAVEPOINT #{SQL.quote_identifier(savepoint)}")
58
+ end
59
+ @transaction_depth += 1
60
+ result = yield
61
+ @transaction_depth -= 1
62
+ if @transaction_depth.zero?
63
+ execute("COMMIT")
64
+ else
65
+ execute("RELEASE SAVEPOINT #{SQL.quote_identifier(savepoint)}")
66
+ end
67
+ result
68
+ rescue Exception
69
+ @transaction_depth -= 1 if @transaction_depth > 0
70
+ if @transaction_depth.zero?
71
+ execute("ROLLBACK")
72
+ else
73
+ execute("ROLLBACK TO SAVEPOINT #{SQL.quote_identifier(savepoint)}")
74
+ execute("RELEASE SAVEPOINT #{SQL.quote_identifier(savepoint)}")
75
+ end
76
+ raise
77
+ end
78
+
79
+ def table_exists?(name)
80
+ !select_value("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", [name.to_s]).nil?
81
+ end
82
+
83
+ def columns(table_name)
84
+ select_all("PRAGMA table_info(#{SQL.quote_identifier(table_name)})").map do |row|
85
+ Column.new(
86
+ row["name"], row["type"], row["notnull"].to_i == 1,
87
+ row["dflt_value"], row["pk"].to_i == 1
88
+ )
89
+ end
90
+ end
91
+
92
+ def indexes(table_name)
93
+ select_all("PRAGMA index_list(#{SQL.quote_identifier(table_name)})")
94
+ end
95
+
96
+ def close
97
+ @driver.close
98
+ end
99
+
100
+ private
101
+
102
+ def normalize_config(config)
103
+ value = config.is_a?(Hash) ? config.dup : { database: config }
104
+ value = value.each_with_object({}) { |(key, item), result| result[key.to_sym] = item }
105
+ database = value[:database]
106
+ raise ArgumentError, "database is required" if database.nil? || database.to_s.empty?
107
+ value[:database] = database.to_s
108
+ value
109
+ end
110
+
111
+ def build_driver(path)
112
+ if RufletRecord.const_defined?(:NativeSQLite)
113
+ NativeDriver.new(path)
114
+ else
115
+ CRubyDriver.new(path)
116
+ end
117
+ end
118
+
119
+ def configure
120
+ execute("PRAGMA foreign_keys = ON") if @config.fetch(:foreign_keys, true)
121
+ timeout = @config.fetch(:timeout, 5_000).to_i
122
+ execute("PRAGMA busy_timeout = #{timeout}")
123
+ if @config[:journal_mode]
124
+ mode = @config[:journal_mode].to_s.upcase
125
+ raise ArgumentError, "invalid journal mode" unless %w[DELETE TRUNCATE PERSIST MEMORY WAL OFF].include?(mode)
126
+ execute("PRAGMA journal_mode = #{mode}")
127
+ end
128
+ end
129
+
130
+ def normalize_binds(binds)
131
+ binds.map do |value|
132
+ if value == true
133
+ 1
134
+ elsif value == false
135
+ 0
136
+ elsif value.is_a?(Time)
137
+ "%04d-%02d-%02d %02d:%02d:%02d" % [
138
+ value.year, value.month, value.day,
139
+ value.hour, value.min, value.sec
140
+ ]
141
+ else
142
+ value
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ class NativeDriver
149
+ def initialize(path)
150
+ @database = RufletRecord::NativeSQLite::Database.new(path)
151
+ end
152
+
153
+ def execute(sql, binds)
154
+ @database.execute(sql, binds)
155
+ end
156
+
157
+ def execute_batch(sql)
158
+ @database.execute_batch(sql)
159
+ end
160
+
161
+ def changes
162
+ @database.changes
163
+ end
164
+
165
+ def last_insert_row_id
166
+ @database.last_insert_row_id
167
+ end
168
+
169
+ def close
170
+ @database.close
171
+ end
172
+ end
173
+
174
+ unless RUBY_ENGINE == "mruby"
175
+ require "sqlite3"
176
+
177
+ class CRubyDriver
178
+ def initialize(path)
179
+ @database = SQLite3::Database.new(path)
180
+ @database.results_as_hash = true
181
+ end
182
+
183
+ def execute(sql, binds)
184
+ @database.execute(sql, binds).map do |row|
185
+ normalized = {}
186
+ row.each { |key, value| normalized[key.to_s] = value unless key.is_a?(Integer) }
187
+ normalized
188
+ end
189
+ end
190
+
191
+ def execute_batch(sql)
192
+ @database.execute_batch(sql)
193
+ []
194
+ end
195
+
196
+ def changes
197
+ @database.changes
198
+ end
199
+
200
+ def last_insert_row_id
201
+ @database.last_insert_row_id
202
+ end
203
+
204
+ def close
205
+ @database.close
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end