Browse Source

Rebasing because phone version of gitea is acting funny

master
Yessiest 7 months ago
parent
commit
351491ba96
  1. 104
      README.md
  2. 354
      lib/lpgar.rb
  3. 18
      lpgar.gemspec

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.
```

354
lib/lpgar.rb

@ -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

18
lpgar.gemspec

@ -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…
Cancel
Save