top | item 45885699

Blending SQL and Python with Sqlorm

66 points| emixam | 3 months ago |hyperflask.dev

25 comments

order

somat|3 months ago

Here is my rather naive take on the same subject. But I had a very different motivation than the author. See I actually quite like SQL and enjoy programming in it, but what I don't like is mixing sql and python. So one night in a flash of inspiration or perhaps a fever dream I wrote this thing that lets you have stand alone parameterized sql queries and you call them like a python function or generator. It is one of those overly clever things where I sort of hate the magic, but I find myself using it more and more which I will probably regret one day.

https://nl1.outband.net/fossil/query/file?name=query.py&ci=t...

In short you have your query in file sql/dept_personal.sql and you call it like

    for row in q.dept_personal(db_cursor, department='manpower'):

atombender|3 months ago

That's basically the idea behind Sqlc [1]. By letting SQL be SQL, you avoid the many awkward mechanisms ORMs need to integrate SQLisms into the native language, and you define the query only in terms of its inputs and outputs, which can be made type-safe since they're declaratively defined.

The downside is that parameterized queries are a bit of a chore; for example, if a query should support an optional filter on user_id, you need to craft it like this:

    WHERE ...
      AND CASE
        WHEN sqlc.narg('user_id') IS NOT NULL THEN sqlc.narg('user_id')
        ELSE true
      END
This is not too bad, though, and the conditionals get optimized away by the database planner.

[1] https://sqlc.dev/

digdugdirk|3 months ago

I honestly have no opinion in this discussion, but I will 100% upvote the first Fossil repository I've seen shared on here! How do you find developing on the Fossil platform?

Rajni07|3 months ago

Really like the idea of keeping SQL explicit while still getting ORM conveniences. The @sqlfunc syntax feels clean, and the no-session approach makes cross DB work simpler. Curious how it handles async or pooling. Seems like a solid middle ground between raw SQL and heavy ORMs.

itopaloglu83|3 months ago

A common problem I found myself in is that I have to develop the query in one file and frequently run it to verify the data accuracy. I define the variables at top reminding me types and limitations etc. int vs varchar(10) vs varchar(50)

So I made a very simple module that takes those sql files and turns them into SQLAlchemy text objects with variables in them.

Would it be possible to add something like this to the project or does it require many sql parsing libraries etc. to ensure sql validity to find variables in the sql file?

tcdent|3 months ago

Kudos for making the leap.

Your pattern of re-interpreting __doc__ is kinda weird though. Why not just add a `return` statement?

duncanfwalker|3 months ago

I guess it's more clear that it should be a to statically readable value? eg you shouldn't do things like use arguments to build the str

harvey9|3 months ago

The author says "the name isn't great", but I like it. Very close to 'squirm' or even 'slurm'.

develatio|3 months ago

I think this is very similar to Django’s ORM.

whinvik|3 months ago

I find the following way of expressing args awkward.

with engine: fn(args)

I would rather have

with engine: fn(args, engine)

This makes testing way easier.

JodieBenitez|3 months ago

> However, I've always felt some of the design choices didn't fit how I like to use an ORM. Notably:

I feel the same, hence why I prefer a Django-like ORM to SQLAlchemy in spite of all the praises it gets. The author says "SQLAlchemy is the best. I don't like the API or codebase of the others", but actually what he describes feels like the Django ORM (or Tortoise, or many others).

Also, sometimes just a thin layer above SQL is fine. For small personal projects I use my own wrapper above sqlite like so:

    import oora
    from dataclasses import dataclass

    db = oora.DB(
        db_path=":memory:",  # or /path/to/your/db.sqlite3
        # migrations are just pairs of key=>val where key is an arbitrary (but unique) label and val is a SQL script or a callable.
        # If val is a callable, it must take a sqlite3.Cursor as first parameter.
        # migrations are executed in order
        migrations={
            # here's an initial migration:
            "0000": "CREATE TABLE IF NOT EXISTS user(id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL);",
            # simulating a schema evolution, let's add a field:
            "0001": "ALTER TABLE user ADD COLUMN email TEXT NULL;",
        },
    )
    db.migrate()

    db.insert("user", {"name": "John"})
    db.insert("user", {"name": "Jack"})
    db.insert("user", {"name": "Jill"})


    # dataclasses are perfect to represent rows
    # while still allowing custom behaviour
    @dataclass
    class User:
        id: int
        name: str
        email: str

        def __str__(self):
            return self.name


    # fetch a random instance
    user = db.hydrate(User, db.execute("select * from user ORDER BY RANDOM() limit 1").fetchone())
    print(f"User(id {user.id}), original name: {user}")

    # change name and email
    user.name = "Richard"
    user.email = "richard@acme.tld"
    db.save(user) # name of table is infered from the dataclass name
    print(f"User(id {user.id}), updated name: {user} <{user.email}>")

    # persist changes
    db.commit()import oora
    from dataclasses import dataclass

    db = oora.DB(
        db_path=":memory:",  # or /path/to/your/db.sqlite3
        # migrations are just pairs of key=>val where key is an arbitrary (but unique) label and val is a SQL script or a callable.
        # If val is a callable, it must take a sqlite3.Cursor as first parameter.
        # migrations are executed in order
        migrations={
            # here's an initial migration:
            "0000": "CREATE TABLE IF NOT EXISTS user(id INTEGER PRIMARY KEY, name TEXT UNIQUE NOT NULL);",
            # simulating a schema evolution, let's add a field:
            "0001": "ALTER TABLE user ADD COLUMN email TEXT NULL;",
        },
    )
    db.migrate()

    db.insert("user", {"name": "John"})
    db.insert("user", {"name": "Jack"})
    db.insert("user", {"name": "Jill"})


    # dataclasses are perfect to represent rows
    # while still allowing custom behaviour
    @dataclass
    class User:
        id: int
        name: str
        email: str

        def __str__(self):
            return self.name


    # fetch a random instance
    user = db.hydrate(User, db.execute("select * from user ORDER BY RANDOM() limit 1").fetchone())
    print(f"User(id {user.id}), original name: {user}")

    # change name and email
    user.name = "Richard"
    user.email = "richard@acme.tld"
    db.save(user) # name of table is infered from the dataclass name
    print(f"User(id {user.id}), updated name: {user} <{user.email}>")

    # persist changes
    db.commit()