Yessiest
7 months ago
3 changed files with 474 additions and 2 deletions
-
104README.md
-
354lib/lpgar.rb
-
18lpgar.gemspec
@ -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 |
Write
Preview
Loading…
Cancel
Save
Reference in new issue