diff --git a/README.md b/README.md index 5d19c70..58a9544 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,103 @@ -# lpgar +# Lightweight Postgres Active Record -Lightweight Postgres Active Record library for ruby \ No newline at end of file +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-.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. +``` diff --git a/lib/lpgar.rb b/lib/lpgar.rb new file mode 100644 index 0000000..f7c29f4 --- /dev/null +++ b/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 diff --git a/lpgar.gemspec b/lpgar.gemspec new file mode 100644 index 0000000..b86e4f6 --- /dev/null +++ b/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