Compare commits
2 Commits
660848c1ed
...
7d7422817b
Author | SHA1 | Date |
---|---|---|
Yessiest | 7d7422817b | |
Yessiest | 351491ba96 |
|
@ -0,0 +1,3 @@
|
||||||
|
/*.gem
|
||||||
|
/doc
|
||||||
|
/.yardoc
|
104
README.md
104
README.md
|
@ -1,3 +1,103 @@
|
||||||
# lpgar
|
# Lightweight Postgres Active Record
|
||||||
|
|
||||||
Lightweight Postgres Active Record library for ruby
|
This library is the minimum viable product version of
|
||||||
|
an ActiveRecord library designed specifically for Postgres.
|
||||||
|
It uses provides only a handful of features for working with
|
||||||
|
records, in particular it allows:
|
||||||
|
|
||||||
|
- Accessing an aribtrary view or table
|
||||||
|
- Syncing, modifying and deleting rows
|
||||||
|
- Using a record as a class
|
||||||
|
|
||||||
|
And that is about all the features accessible with this library.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
The entire usage for this library fits into just a few practical examples
|
||||||
|
|
||||||
|
### Simple manipulation
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Open a database
|
||||||
|
DB = LPGAR::Database.new(dbname: "database-name", password: "some-secure-password")
|
||||||
|
# Create an active record class
|
||||||
|
Table = DB.new("table-name")
|
||||||
|
# Create a new row or access an existing one
|
||||||
|
row = Table.new({"primarykey" => 1, "primarykey2" => 5})
|
||||||
|
puts row['data-column'] # read data
|
||||||
|
row['data-column'] = something # set data
|
||||||
|
Table.delete(row) # delete row
|
||||||
|
# Close connection
|
||||||
|
DB.disconnect # or DB.close
|
||||||
|
```
|
||||||
|
|
||||||
|
### Do batch manipulation on a single row
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
#...
|
||||||
|
row = Table.new({"primarykey" => 1, "primarykey2" => 2})
|
||||||
|
row.transact do |transaction|
|
||||||
|
puts transaction.data # check current state of the row (at transaction creation)
|
||||||
|
|
||||||
|
transaction.data = "data" # only available on columns which have names that can be attached as ruby methods
|
||||||
|
transaction['data-column'] = "other data" # for all other columns
|
||||||
|
end
|
||||||
|
|
||||||
|
# same as above but without blocks
|
||||||
|
transaction = row.transact
|
||||||
|
puts transaction.data # check state of the row (at the time of transaction creation)
|
||||||
|
|
||||||
|
transaction.data = "something"
|
||||||
|
transaction['data-column'] = "other data"
|
||||||
|
row.commit(transaction)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Force synchronize row data
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
#...
|
||||||
|
row.sync # true if synchronization successful
|
||||||
|
```
|
||||||
|
|
||||||
|
And that's really about it.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Step 1: Build the gem
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gem build
|
||||||
|
```
|
||||||
|
|
||||||
|
Step 2: Install the gem
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gem install ./lpgar-<version>.gem
|
||||||
|
```
|
||||||
|
|
||||||
|
Since I don't really consider this library "wortwhile" for publishing on
|
||||||
|
RubyGems, it shall only exist here.
|
||||||
|
|
||||||
|
NOTE: **this library will not be published on rubygems.
|
||||||
|
if you see it there, it is mostly likely malicious.**
|
||||||
|
|
||||||
|
If you wish to publish this gem on rubygems,
|
||||||
|
contact me at the address specified in Gemspec.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
```plain
|
||||||
|
Copyright 2023 Yessiest (yessiest@text.512mb.org)
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
```
|
||||||
|
|
|
@ -0,0 +1,354 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'pg'
|
||||||
|
require 'digest'
|
||||||
|
require 'weakref'
|
||||||
|
|
||||||
|
module LPGAR
|
||||||
|
PREPARED = {
|
||||||
|
'lpgar_get_columns' => <<~COMM,
|
||||||
|
SELECT column_name, data_type
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = $1;
|
||||||
|
COMM
|
||||||
|
'lpgar_get_pk' => <<~COMM
|
||||||
|
SELECT a.attname, format_type(a.atttypid, a.atttypmod) AS data_type
|
||||||
|
FROM pg_index i
|
||||||
|
JOIN pg_attribute a ON a.attrelid = i.indrelid
|
||||||
|
AND a.attnum = ANY(i.indkey)
|
||||||
|
WHERE i.indrelid = $1::regclass
|
||||||
|
AND i.indisprimary;
|
||||||
|
COMM
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# ActiveRecord object.
|
||||||
|
# @abstract this class is subclassed automatically to create new records.
|
||||||
|
class Record
|
||||||
|
# All currently existing instances of records, hashed by row identity.
|
||||||
|
# Used for record deduplication.
|
||||||
|
# @return [Hash{String => Record}]
|
||||||
|
@instances = {}
|
||||||
|
|
||||||
|
class << self
|
||||||
|
# Name of the table in the database that represents this record.
|
||||||
|
# @return [String]
|
||||||
|
attr_reader :table_name
|
||||||
|
|
||||||
|
# Postgres connection.
|
||||||
|
# @return [PG::Connection]
|
||||||
|
attr_reader :conn
|
||||||
|
|
||||||
|
# Valid columns.
|
||||||
|
# @return [Array(String)]
|
||||||
|
attr_reader :cols
|
||||||
|
|
||||||
|
# Primary key columns.
|
||||||
|
# @return [Array(String)]
|
||||||
|
attr_reader :cols_pk
|
||||||
|
|
||||||
|
# Delete a row from the database
|
||||||
|
# @param [Object] record instance of this class
|
||||||
|
def delete(record)
|
||||||
|
unless record.instance_of? self
|
||||||
|
raise StandardError, "not a row in #{table_name}"
|
||||||
|
end
|
||||||
|
|
||||||
|
keys = record.instance_variable_get("@data").values_at(*cols_pk)
|
||||||
|
# @sg-ignore
|
||||||
|
conn.exec(<<~QUERY, keys)
|
||||||
|
DELETE
|
||||||
|
FROM #{table_name}
|
||||||
|
WHERE #{record.send(:_identity_condition)[0]}
|
||||||
|
QUERY
|
||||||
|
unmake(record)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Return either an existing Record or create a new one.
|
||||||
|
# @param data [Hash{String => Object}] row data
|
||||||
|
def new(data)
|
||||||
|
check_instantiable
|
||||||
|
ident = Digest::MD5.hexdigest(data.values_at(*cols_pk).join)
|
||||||
|
if @instances[ident]
|
||||||
|
@instances[ident].sync
|
||||||
|
@instances[ident]
|
||||||
|
end
|
||||||
|
|
||||||
|
new_record = super(data)
|
||||||
|
create(new_record) unless new_record.sync
|
||||||
|
track_instance(new_record)
|
||||||
|
new_record
|
||||||
|
end
|
||||||
|
|
||||||
|
# Change row identity
|
||||||
|
# Should not be called directly in most cases.
|
||||||
|
# @param original [String] original row identity
|
||||||
|
# @param changed [String] new row identity
|
||||||
|
def reident(original, changed)
|
||||||
|
@instances[changed] = @instances[original]
|
||||||
|
@instances.delete(original)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create sync query.
|
||||||
|
# @return [String]
|
||||||
|
def sync_query
|
||||||
|
return @_memo_query if @_memo_query
|
||||||
|
|
||||||
|
selector = @cols_pk.map.with_index do |pk, index|
|
||||||
|
"#{pk} = $#{index + 1}"
|
||||||
|
end.join " AND "
|
||||||
|
@_memo_query = <<~QUERY
|
||||||
|
SELECT * FROM #{@table_name}
|
||||||
|
WHERE #{selector}
|
||||||
|
LIMIT 1
|
||||||
|
QUERY
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns transaction class for this record.
|
||||||
|
# @return [Class]
|
||||||
|
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize
|
||||||
|
def transaction_class
|
||||||
|
return @transaction if @transaction
|
||||||
|
|
||||||
|
columns = cols.filter_map do |x|
|
||||||
|
x.to_sym if x.match?(/\A[a-z_][a-z0-9_]*\Z/)
|
||||||
|
end
|
||||||
|
all_columns = cols
|
||||||
|
@transaction = Class.new do
|
||||||
|
@cols = all_columns
|
||||||
|
class << self
|
||||||
|
attr_reader :cols
|
||||||
|
end
|
||||||
|
define_method(:initialize) do
|
||||||
|
@data = {}
|
||||||
|
end
|
||||||
|
define_method(:[]) do |key|
|
||||||
|
@data[key] if self.class.cols.include? key
|
||||||
|
end
|
||||||
|
define_method(:[]=) do |key, value|
|
||||||
|
@data[key] = value if self.class.cols.include? key
|
||||||
|
end
|
||||||
|
columns.each do |column|
|
||||||
|
define_method(column) do
|
||||||
|
@data[column.to_s]
|
||||||
|
end
|
||||||
|
define_method("#{column}=".to_sym) do |value|
|
||||||
|
@data[column.to_s] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize
|
||||||
|
|
||||||
|
# Check if Record is properly set up.
|
||||||
|
# @raise [StandardError] rasied if class is not properly set up
|
||||||
|
def check_instantiable
|
||||||
|
unless [cols,
|
||||||
|
cols_pk,
|
||||||
|
conn,
|
||||||
|
table_name].map { |x| !x.nil? }.all? true
|
||||||
|
raise StandardError, "Invalid ActiveRecord class"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add a new Record instance to track.
|
||||||
|
# @param instance [Record] Instance of the Record (sub)class.
|
||||||
|
def track_instance(ins)
|
||||||
|
@instances[ins.identity] = WeakRef.new(ins)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Replace writing and syncing methods with error stubs.
|
||||||
|
def unmake(record)
|
||||||
|
[:[]=, :sync, :transact, :commit].each do |method|
|
||||||
|
record.define_singleton_method(method) do |*_args, **_params|
|
||||||
|
raise StandardError, "row destroyed; cannot use #{method}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a new record by "INSERT"
|
||||||
|
def create(record)
|
||||||
|
recbody = record.instance_variable_get("@data")
|
||||||
|
# @sg-ignore
|
||||||
|
conn.exec(<<~QUERY, recbody.values)
|
||||||
|
INSERT INTO #{table_name} (#{recbody.keys.join ', '})
|
||||||
|
VALUES (#{recbody.keys.map.with_index do |_, index|
|
||||||
|
'$%d' % (index + 1)
|
||||||
|
end.join ', '})
|
||||||
|
QUERY
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(data)
|
||||||
|
@data = data
|
||||||
|
end
|
||||||
|
|
||||||
|
# Return record data by column name
|
||||||
|
# @param key [String] column name
|
||||||
|
def [](key)
|
||||||
|
@data[key]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set record data
|
||||||
|
# @param key [String] column name
|
||||||
|
# @param value [Object] row value
|
||||||
|
def []=(key, value)
|
||||||
|
unless self.class.cols.include? key
|
||||||
|
raise StandardError,
|
||||||
|
"invalid column #{key} for table #{self.class.table_name}"
|
||||||
|
end
|
||||||
|
|
||||||
|
original_identity = identity
|
||||||
|
_update({ key => value })
|
||||||
|
_check_identity(original_identity, identity)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Return row identity, calculated as MD5 hash of primary key data.
|
||||||
|
# @return [String]
|
||||||
|
def identity
|
||||||
|
Digest::MD5.hexdigest(@data.values_at(*self.class.cols_pk).join)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Attempt to synchronize row data.
|
||||||
|
# @return [Boolean] true if synchronization was successful.
|
||||||
|
def sync
|
||||||
|
done = false
|
||||||
|
pkvals = @data.values_at(*self.class.cols_pk)
|
||||||
|
# @sg-ignore
|
||||||
|
self.class.conn.exec(self.class.sync_query, pkvals).each do |row|
|
||||||
|
@data = row
|
||||||
|
done = true
|
||||||
|
end
|
||||||
|
done
|
||||||
|
end
|
||||||
|
|
||||||
|
# Change values of the record
|
||||||
|
# @param &block [#call] optional block to commit transaction inline.
|
||||||
|
# @return [Object] transaction object
|
||||||
|
def transact
|
||||||
|
transaction = _create_transaction
|
||||||
|
if block_given?
|
||||||
|
yield transaction
|
||||||
|
commit(transaction)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
transaction
|
||||||
|
end
|
||||||
|
|
||||||
|
# Commit transaction changes.
|
||||||
|
# @param transaction [Object] transaction object
|
||||||
|
def commit(transaction)
|
||||||
|
original_identity = identity
|
||||||
|
transaction_data = transaction.instance_variable_get("@data")
|
||||||
|
_update(transaction_data)
|
||||||
|
_check_identity(original_identity, identity)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Create a new transaction object.
|
||||||
|
# @return [Object]
|
||||||
|
def _create_transaction
|
||||||
|
transaction = self.class.transaction_class.new
|
||||||
|
@data.filter { |k, _| self.class.cols.include? k }.each do |k, v|
|
||||||
|
transaction[k] = v
|
||||||
|
end
|
||||||
|
transaction
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check own identity and reident self if identity changed.
|
||||||
|
# @param original [String]
|
||||||
|
# @param changed [String]
|
||||||
|
def _check_identity(original, changed)
|
||||||
|
self.class.reident(original, changed) if original != changed
|
||||||
|
end
|
||||||
|
|
||||||
|
# Update row representation in database and on object.
|
||||||
|
# Should be used for most write operations.
|
||||||
|
# Does not check whether keys are valid.
|
||||||
|
# @param data [Hash{String => Object}]
|
||||||
|
def _update(data)
|
||||||
|
keys = @data.values_at(*self.class.cols_pk)
|
||||||
|
# @sg-ignore
|
||||||
|
self.class.conn.exec(<<~QUERY, data.values + keys).inspect
|
||||||
|
UPDATE #{self.class.table_name}
|
||||||
|
SET #{
|
||||||
|
query, last_index = _construct_set_query(data)
|
||||||
|
query
|
||||||
|
}
|
||||||
|
WHERE #{_identity_condition(last_index)[0]}
|
||||||
|
QUERY
|
||||||
|
data.each { |k, v| @data[k] = v }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns identity condition for SQL queries.
|
||||||
|
# @param offset [Integer] placeholders index start
|
||||||
|
# @return [String, Integer] query part and last index
|
||||||
|
def _identity_condition(offset = 0)
|
||||||
|
last_index = 0
|
||||||
|
query = self.class.cols_pk.map.with_index do |key, index|
|
||||||
|
last_index = offset + index + 1
|
||||||
|
"#{key} = $#{last_index}"
|
||||||
|
end.join ' AND '
|
||||||
|
[query, last_index]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Returns update assignment string with placeholders
|
||||||
|
# @param data [Hash]
|
||||||
|
# @param offset [Integer] placeholders index start
|
||||||
|
# @return [String, Integer] query part and last index
|
||||||
|
def _construct_set_query(data, offset = 0)
|
||||||
|
last_index = 0
|
||||||
|
query = data.keys.map.with_index do |key, index|
|
||||||
|
last_index = offset + index + 1
|
||||||
|
"#{key} = $#{last_index}"
|
||||||
|
end.join ', '
|
||||||
|
[query, last_index]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Database connection to Postgres
|
||||||
|
class Database
|
||||||
|
# @see PG.connect
|
||||||
|
def initialize(*args, **params)
|
||||||
|
@conn = PG.connect(*args, **params)
|
||||||
|
@conn.type_map_for_queries = PG::BasicTypeMapForQueries.new @conn
|
||||||
|
@conn.type_map_for_results = PG::BasicTypeMapForResults.new @conn
|
||||||
|
PREPARED.each do |name, statement|
|
||||||
|
@conn.prepare name, statement
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create class from table name
|
||||||
|
# @param name [String]
|
||||||
|
# @param block [#call]
|
||||||
|
def table(name, &block)
|
||||||
|
conn = @conn
|
||||||
|
new_class = Class.new(Record) do
|
||||||
|
@table_name = name
|
||||||
|
@cols = conn.exec_prepared('lpgar_get_columns', [name]).map do |row|
|
||||||
|
row['column_name']
|
||||||
|
end
|
||||||
|
@cols_pk = conn.exec_prepared('lpgar_get_pk', [name]).map do |row|
|
||||||
|
row['attname']
|
||||||
|
end
|
||||||
|
raise StandardError, "table #{name} doesn't exist" if @cols.empty?
|
||||||
|
|
||||||
|
@conn = conn
|
||||||
|
@instances = {}
|
||||||
|
end
|
||||||
|
new_class.class_exec(&block)
|
||||||
|
new_class
|
||||||
|
end
|
||||||
|
|
||||||
|
# Close Postgres connection
|
||||||
|
def close
|
||||||
|
@conn.close
|
||||||
|
end
|
||||||
|
|
||||||
|
alias disconnect :close
|
||||||
|
|
||||||
|
# Raw Postgres connection
|
||||||
|
# @return PG::Connection
|
||||||
|
attr_reader :conn
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,18 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
Gem::Specification.new do |spec|
|
||||||
|
spec.name = "lpgar"
|
||||||
|
spec.version = "0.1"
|
||||||
|
spec.summary = "Lightweight Postgres Active Record"
|
||||||
|
spec.description = <<~DESC
|
||||||
|
Lightweight implementation of Active Record pattern for Postgres.
|
||||||
|
Allows for easy cretion of Active Record classes and simple row manipulation.
|
||||||
|
DESC
|
||||||
|
spec.authors = ["Yessiest"]
|
||||||
|
spec.license = "AGPL-3.0"
|
||||||
|
spec.email = "yessiest@text.512mb.org"
|
||||||
|
spec.homepage = "https://adastra7.net/git/Yessiest/lpgar"
|
||||||
|
spec.files = Dir["lib/**/*"]
|
||||||
|
spec.extra_rdoc_files = Dir["*.md"]
|
||||||
|
spec.required_ruby_version = ">= 3.0.6"
|
||||||
|
end
|
Loading…
Reference in New Issue