Audrey

PLEASE NOTE: Audrey is in the very early stages of development. You're welcome to try it out, but it's not ready for prime time yet.

Audrey is an easy, yet powerful database system. With Audrey you can create your database and start using it in a few lines of code.

Install

gem install audrey

Basic usage

By default, Audrey uses SQLite for storage, so the only dependencies are SQLite, which is standard on most Unixish systems, and this gem. To create and start using an Audrey database, all you have to do is open it with Audrey.connect, giving a path to a database file and a read/write mode - 'rw', 'r', or 'w'. The file doesn't need to already exist; it will be created automatically as needed.

require 'audrey'
path = '/tmp/my.db'

Audrey.connect(path, 'rw') do |db|
end

It its simplest use, use the db object as a hash to store information:

Audrey.connect(path, 'rw') do |db|
    db['hero'] = 'Thor'
    db['antagonist'] = 'Loki'
    db['score'] = 1.3
    db['ready'] = true

    db.each do |k, v|
        puts k + ': ' + v.to_s
    end
end

That gives us this output:

hero: Thor
antagonist: Loki
score: 1.3
ready: true

Audrey can store complex structures such as nested arrays and hashes.

Audrey.connect(path, 'rw') do |db|
    db['people'] = {}
    people = db['people']

    people['fred'] = {'name'=>'Fred'}
    people['mary'] = {'name'=>'Mary', 'towns'=>['Blacksburg', 'Seattle']}

    people['mary']['friend'] = people['fred']

    puts db['people']['mary']['name']
    puts db['people']['mary']['towns'].join(', ')
    puts db['people']['mary']['friend']['name']
end

Take note of the line that reads people['mary']['friend'] = people['fred']. Audrey doesn't use foreign keys. Instead, you simply link objects directly to each other. So in this case, fred is an element in the people hash, but it is also an element in Mary's hash with the key friend. Objects can be linked in this free form manner without the need for setting up lookup tables or defining foreign keys.

Custom classes

Audrey provides a system for defining your own classes. In this way, you can define your own classes, using their properties and methods as usual. Objects of those classes are automatically stored in the Audrey database and can be retrieved, as objects, for use.

Consider, for example, this simple class:

class Person < Audrey::Object::Custom
    self.fco = true
    field 'first'
    field 'middle'
    field 'surname'
end

This class inherits Audrey::Object::Custom, which almost all of your Audrey classes should do.

The next line, self.fco = true, is important. Every Audrey class must be set at self.fco = true or self.fco = false. fco mean "first class object". When the database is closed, objects that are not first class objects, and are not descended from first class objects, are purged. Every custom class must be explicitly set with fco=true or fco=false. Think of clases withfco=false as being "cascade delete".

The next few lines define fields for a person record, first, middle and surname.

So we can use our class like this:

Audrey.connect(path, 'rw') do |db|
    mary = Person.new()
    mary.first = 'Mary'
    mary.middle = 'F.'
    mary.surname = 'Sullivan'

    fred = Person.new()
    fred.first = 'Fred'
    fred.middle = 'C.'
    fred.surname = 'Murray'
end

Later we're going to need to find the Person records. The simplest way to get to them is to each them with the Person class itself:

Audrey.connect(path, 'rw') do |db|
    Person.each do |person|
        puts person.first
    end
end

Notice that there is no need to reinstantiate the objects using data from the database. Audrey automatically creates the objects from the stored data, and makes them available for use as they were originally created.

The example above produces this output:

Fred
Mary

Under the hood, each uses a type of query called q0. Later we'll look at more details about how you can use Q0.

Subclassing

Like any Ruby class, you can subclass your Audrey classes. For example, let's subclass Person with Guest, and subclass Guest with Preferred:

class Guest < Person
    field 'stays'
end

class Preferred < Guest
    field 'rating'
end

Notice that we didn't bother to set these subclasses as first class objects -- they inherited that property from Person. We also added a field to each of our new subclasses. Now we can add Guest and Preferred objects to the database:

Audrey.connect(path, 'rw') do |db|
    dan = Guest.new()
    dan.first = 'Dan'
    dan.stays = 10

    pete = Preferred.new()
    pete.first = 'Pete'
    pete.stays = 12
    pete.rating = 'gold'
end

Because Guest and Preferred derive from Person, we can iterate through all of the records using Person, including objects from its derived classes.

Audrey.connect(path, 'rw') do |db|
    Person.each do |person|
        puts person.first
    end
end

which produces this output:

Fred
Pete
Mary
Dan

Autocommit and transactions

In all the examples so far, data has been written to the Audrey database automatically as it has been produced. However, you might want to only atomically commit data at specified points. You can do this using the autocommit feature, or transactions.

autocommit

To keep Audrey from automatically writing data, use the autocommit option in connect, like this:

Audrey.connect(path, 'rw', 'immediate_commit'=>false) do |db|
    db['hero'] = 'Thor'
end

In the example above, the data is never committed by the end of the session, so if we try to retrieve the data:

Audrey.connect(path, 'rw') do |db|
    puts 'hero: ', db['hero']
end

... we get this disappointing output:

hero:

To commit, simply use the commit method:

Audrey.connect(path, 'rw', 'immediate_commit'=>false) do |db|
    db['hero'] = 'Thor'
    db.commit
end

Which will give us more fulfilling results:

hero:
Thor

Any time during the session you can use rollback to rollback to the previous commit or to the state of the database when it was opened. So this code:

Audrey.connect(path, 'rw', 'immediate_commit'=>false) do |db|
    db['antagonist'] = 'Loki'
    db.rollback
    puts 'antagonist: ', db['antagonist']
end

... will output without Loki:

antagonist:

transaction

For more fine-grained control of when data is commited, you might prefer to use transaction. Any time during a database session you can start a transaction block. Changes to the database inside that block are not committed without an explicit commit command. For example, consider this code::

Audrey.connect(path, 'rw') do |db|
    db['hero'] = 'Thor'

    db.transaction do |tr|
        db['antagonist'] = 'Loki'
    end

    db.each do |k, v|
        puts k + ': ' + v
    end
end

The database session is set to automatically commit data as it is created (because autocommit defaults to true). However, when we use db.transaction to start a transaction block. Within that block, data is not automatically committed. So the output for this code will look like this:

hero: Thor

Inside a transaction block, you can commit by calling the transaction's commit method:

Audrey.connect(path, 'rw') do |db|
    db['hero'] = 'Thor'

    db.transaction do |tr|
        db['antagonist'] = 'Loki'
        tr.commit
    end

    db.each do |k, v|
        puts k + ': ' + v
    end
end

That gives us this output:

hero: Thor
antagonist: Loki

Rollback transactions with the rollback method. For example, in this code we use both rollback and commit:

Audrey.connect(path, 'rw') do |db|
    db['hero'] = 'Thor'

    db.transaction do |tr|
        db['antagonist'] = 'Loki'
        tr.rollback
        db['ally'] = 'Captain America'
        tr.commit
    end

    db.each do |k, v|
        puts k + ': ' + v
    end
end

In that example, we set db['antagonist'] = 'Loki'. But in the next line we roll it back. Then in the next two lines we set db['ally'] = 'Captain America' and commit it. The result is that antagonist is never committed but ally is, producing this output:

hero: Thor
ally: Captain America

Transactions can be nested. For example, in this code, the outer transaction is committed, but not the inner transaction:

Audrey.connect(path, 'rw') do |db|
    db['hero'] = 'Thor'

    db.transaction do |tr1|
        db['antagonist'] = 'Loki'

        db.transaction do |tr2|
            db['ally'] = 'Captain America'
        end

        tr1.commit
    end

    db.each do |k, v|
        puts k + ': ' + v
    end
end

That gives us this output

hero: Thor
antagonist: Loki

If an inner transaction is committed, but not the outer transaction, then nothing in the inner transaction is finally committed. So, for example, consider this code:

Audrey.connect(path, 'rw') do |db|
    db['hero'] = 'Thor'

    db.transaction do |tr1|
        db['antagonist'] = 'Loki'

        db.transaction do |tr2|
            db['ally'] = 'Captain America'
            tr2.commit
        end
    end

    db.each do |k, v|
        puts k + ': ' + v
    end
end

In that example we commit tr2. But tr2 is nested inside tr1, which is never committed. Therefore everything inside tr1 is rolled back, giving us this output:

hero: Thor

Exiting database connections and transactions

You might find that in some situations you don't need to continue a transaction, or even an entire database connection, if certain conditions are met. For example, suppose you want to add a record using web parameters, but only if the surname is given. You might do that like this:

Audrey.connect(path, 'rw') do |db|
    db.transaction do |tr|
        person = Person.new()
        person.surname = cgi['surname']

        if not person.surname
            tr.exit
        end

        person.first = cgi['first']
        person.middle= cgi['middle']
        puts 'commit'
        tr.commit
    end
end

In this example, if no surname is given, the transaction stops when it hits tr.exit. Nothing else in the transaction after that line is run.

You can also exit an entire database session with db.exit. So, using the same business rules as above, you might code your database connection like this:

Audrey.connect(path, 'rw') do |db|
    if not cgi['surname']
        db.exit
    end

    person = Person.new()
    person.surname = cgi['surname']
    person.first = cgi['first']
    person.middle= cgi['middle']
end

Queries with Q0

Audrey provides a query system called Q0. With Q0 you can perform basic queries on objects, searching for by class and by field value.

Q0 will not be the only query language

Before we go further, it's important to understand what Q0 is not: it is not the only query language that Audrey will ever have. One of the problems that database systems often have is that their query languages becomes more and more convoluted as needs evolve. SQL is a good example. What started as a simple system with English-like syntax evolved into a bizarre language with all manner of join and function syntaxes. So, instead of assuming that we can invent a query language that will always provide all needs, Audrey is designed to allow for multiple query languages. As needs evolve and Q0 becomes unsuitable for advanced needs, new query languages can be invented and added into the Audrey system.

Audrey will always have Q0. As long as they don't create problems with backward compatibility, new features can be added to Q0.

Search by class

Let's start with a simple example. In the following code, we create a Q0 object with db.q0. We tell the query to look for everything in the Person class. Then we each through the results:

Audrey.connect(path, 'rw') do |db|
    query = db.q0
    query.aclass = Person

    query.each do |person|
        puts person.surname
    end
end

Note that to search by class we use fclass (for Audrey class), not just class. There are several reasons for this. First, the class property is already taken. Second, as Audrey grows and is implemented by languages besides Ruby, it will be necessary to distinguish because a Ruby class and an Audrey class. Audrey classes have the same names as Ruby classes, but other languages, such as Python, use a different naming scheme for classes.

The example above is functionally identical to one of the earlier examples in which we use the Person class itself to search for records:

Audrey.connect(path, 'rw') do |db|
    Person.each do |person|
        puts person.first
    end
end

Search by field values

Now we're going to filter by the values of fields. In this example, we set the query to look for records in which the object's first field is "Mary".

Audrey.connect(path, 'rw') do |db|
    query = db.q0
    query.aclass = Person
    query.fields['first'] = 'Mary'

    query.each do |person|
        puts person.surname
    end
end

To search for multiple possible values of a field, use an array that contains all possible values:

query.fields['first'] = ['Mary', 'Fred']

To search for records in which the field is nil or missing, use nil:

query.fields['first'] = nil

To search for any value, as long as it is not nil, use the query's defined method:

query.fields['first'] = query.defined

You can mix and match these options in an array:

query.fields['first'] = ['Mary', nil]

Count

In addition to looping through query results, you can also get just a count of how many objects are found. Simply use the query's count method:

puts query.count()

Other query filters

Currently, Q0 only allows you to search by fclass and field values. More filters will be added as Audrey develops.

Speed

I haven't done any benchmark tests on Audrey yet. I would be very interested to see some if anybody would like to contribute in that way.

That being said, Audrey is probably not currently very fast. Keep in mind, though, that it's worthwhile to balance execution speed against development speed. Audrey, in its current implementation, probably isn't particularly fast in execution, but it allows you go from no project to working project faster than most database systems. Consider the tradeoff.

The name

Audrey is named after the character Audrey in Shakespeare's As You Like It.

Author

Mike O'Sullivan [email protected]

History

version date notes
0.3 Feb 12, 2020 Initial upload.
0.3.1 Feb 12, 2020 Minor fixes to documentation.
0.3.2 Feb 14, 2020 Minor fixes to documentation. Minor code cleanup.