Compare commits
	
		
			2 Commits
		
	
	
		
			660848c1ed
			...
			7d7422817b
		
	
	| Author | SHA1 | Date | 
|---|---|---|
|  | 7d7422817b | |
|  | 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