Compare commits
No commits in common. "7d7422817b72a16f29aaf07edb6aaaca55e0d933" and "660848c1ed1de6d72c79542aa2aac427b603f112" have entirely different histories.
7d7422817b
...
660848c1ed
|
@ -1,3 +0,0 @@
|
||||||
/*.gem
|
|
||||||
/doc
|
|
||||||
/.yardoc
|
|
104
README.md
104
README.md
|
@ -1,103 +1,3 @@
|
||||||
# Lightweight Postgres Active Record
|
# lpgar
|
||||||
|
|
||||||
This library is the minimum viable product version of
|
Lightweight Postgres Active Record library for ruby
|
||||||
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
354
lib/lpgar.rb
|
@ -1,354 +0,0 @@
|
||||||
# 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
|
|
|
@ -1,18 +0,0 @@
|
||||||
# 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