Tracking program investments in SQLite


sqlite cte hierarchical data

This article is part of a series.

    Using SQLite to work with hierarchical data and learning about CTEs and data loading along the way.

    As part of learning more about common table expressions (CTEs) in SQLite I chose to look at some complex queries I needed to make for a hierarchical data set of employees. See the the first article in this series for the setup.

    NOTE: I recommend checking out my Today I Learned on SQLite schema work to learn more.

    Entity relationships and schema

    The basic premise in my Investment Tracker is that a Person can invest some percentage of their time, between 0% and 100%, in an Initiative. An Initiative is a project or a program or any other bundle of work that a number of people, or parts of an organization, are working on (“investment their time in”). The is tracked as a staff Investment of effort. And to keep things organized, Investments can have tags like enterprise and keep-the-lights-on.

    erDiagram Person ||--o{ Investment : makes Investment ||--|| Initiative : invests-in Initiative }|--|{ Tags : has

    As I mentioned in the opening article of the series, this data all lives in a couple of YAML files. Before I transformed the data from YAML into SQLite I needed to create the schema. Here’s what I eventually came up with after trying a few different shapes. Remember again, this is the basic data I was going to pull in from YAML. There’s more to SQL to come before I can answer my questions around this hierarchical data.

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    
    CREATE TABLE people (
      id            TEXT  PRIMARY KEY,
      name          TEXT  NOT NULL,
      email         TEXT  NOT NULL,
      title         TEXT  NOT NULL,
      employee_type TEXT  NOT NULL,
      cost_center   TEXT  NOT NULL,
      manager       TEXT REFERENCES people(id)
    );
    
    CREATE UNIQUE INDEX idx_people_id
    ON people (id);
    
    CREATE INDEX idx_people_manager
    ON people (manager);
    
    CREATE TABLE initiatives (
      id  TEXT  PRIMARY KEY,
      name TEXT NOT NULL,
      url TEXT,
      description TEXT
    );
    
    CREATE UNIQUE INDEX idx_initiatives_id
    ON initiatives(id);
    
    CREATE TABLE tags (
      id  TEXT  PRIMARY KEY
    );
    
    CREATE UNIQUE INDEX idx_tag_id
    on tags(id);
    
    CREATE TABLE initiative_tags (
      initiative TEXT NOT NULL,
      tag TEXT NOT NULL,
    
      PRIMARY KEY(initiative, tag),
      FOREIGN KEY (initiative) REFERENCES initiatives(id),
      FOREIGN KEY (tag) REFERENCES tags(id)
    );
    
    CREATE INDEX idx_initiativetags_initiative
    ON initiative_tags(initiative);
    
    CREATE INDEX idx_initiativetags_tag
    ON initiative_tags(tag);
    
    CREATE TABLE staff_investments (
      initiative TEXT NOT NULL,
      person TEXT NOT NULL,
      investment REAL NOT NULL,
    
      PRIMARY KEY(initiative, person),
      FOREIGN KEY (initiative) REFERENCES initiatives(id),
      FOREIGN KEY (person) REFERENCES people(id)
    );
    
    CREATE INDEX idx_staffinvest_initiative
    ON staff_investments(initiative);
    
    CREATE INDEX idx_staffinvest_person
    ON staff_investments(person);
    

    Get data from YAML to SQLite

    I experimented with a few different ways to get the data from YAML into SQLite. For example, I started with some fancy (read: complicated) code using yaml attributes on Go structs to unmarshal the YAML into a Go struct. Then I used the database/sql package to insert the data into the database. But to be honest there was some pre-processing that I wanted to do beforehand, and I eventually ditched all that yaml-annotate Go code in favor of using the yq command line tool to transform the YAML into CSV, which I could then more easily import into SQLite.

    I kind of regret that decision just because the yq incantations were a real struggle to get right. I spent hours trying to get the subsets and pivots and joins and filters just right.

    Nonetheless, it’s an incredible tool following in the footsteps of jq. Here’s a reminder what the input formats look like. First, each person record:

    1
    2
    3
    4
    5
    6
    7
    
    bitsbyd:
        manager: Alice
        github_login: bitsbyd
        name: Dylan Thomas
        email: XXXXXXX
        title: Senior Director Technical Program Management
        cost_center: XXXXXX

    And then the investments that link to people:

    1
    2
    3
    4
    5
    6
    7
    8
    
    super-secret:
        name: Super Secret
        link: https://www.github.com
        description: >
          Something neat *goes here*.      
        investments:
        - bitsbyd: 1.0
        - jane: 0.8

    Now remember, I’m trying to get CSV files fit for import into the SQL schema defined above.

    Get the people

    And I was doing this in a Taskfile.yaml so watch out for the {{ and }} syntax in the yq commands. The yq steps look like this.

    1
    2
    3
    
     # This flattens the YAML and correctly orders the subset of fields ready for SQL import
    yq '.[] | [key, .name, .email, .title, .employment_type, .cost_center, .manager] \
      | @csv' {{.EMPLOYEE_DATA}} > {{.EMP_CSV}}
    
    1. The .[] creates an array of the top-level keys in the YAML.
    2. The | pipes the output of the previous step into the next step.
    3. The [key, .name, .email, .title, .employment_type, .cost_center, .manager] is a list of the fields I want to keep.
    4. The | @csv converts the list into a CSV string.
    5. The > redirects the output to a file.

    So in one step I’m flattening the YAML, selecting the fields I want, and writing the output to a CSV file. I have a row-per-person in a CSV like this:

    ...
    bitsbyd,Dylan Thomas,bitsbyd@github.com,Senior Director Technical Program Management, \
      employee,XXXXDEPT,Alice
    ...
    

    Get the investments

    The second transform gets basic info on each investment:

    1
    
    yq '.[] | [key, .name, .link, .description] | @csv' {{.INVEST_DATA}} > {{.INV_CSV}}
    

    The same pattern basically, leaving me with a line-per-investment like so:

    ...
    super-secret,Super Secret,https://www.github.com,"Something neat *goes here*."
    ...
    

    Get the person investments by initiative

    This is where is got mind-bendingly complicated…for me at least. You know when you just read the docs super carefully, then even more deliberately craft the command, and it does something entirely unexpected…like nothing, or something just weird? That was a few hours (hours!) of my life at this point.

    1
    2
    3
    
    # This extracts the staff-level investment percentages for each investment
    yq '.[].investments[] | [parent() | parent() | key,keys,.[]] \
      | flatten | @csv' {{.INVEST_DATA}} > {{.INV_STAFF_CSV}}
    

    The whole keys,keys,.[]. I honestly don’t know how I even landed on that as I reread it. But it works so…I’ll take it.

    The output is this with one line for each unique person-investment-initiative combination. Here we see that BitsByD (me) is saying that he has invested 100% of his time on the super-secret project.

    ...
    super-secret,bitsbyd,1.0
    ...
    

    Tags, anyone?

    The final couple of commands are relatively straightforward. They extract unique tag values from across all investments (like a DISTINCT), and then I grab the tags-per-initiative as well:

    1
    2
    3
    
    yq '.[].tags[]' {{.INVEST_DATA}} | uniq > {{.TAGS_CSV}}
    # Grab the tags for each investment for many-to-many lookup
    yq e '.[].tags[] | [parent() | parent() | key,.] | @csv' {{.INVEST_DATA}} > {{.INV_TAGS_CSV}}
    

    End result? I have several CSV files containing normalized data ready to be loaded into my SQLite database.

    Get the CSVs into SQLite

    When I first started tinkering on this project I used the sqlc tool to generate Go code for the SQL schema. It’s really freaking cool, and that’s where the strict separation of SQL schema from any other query or data modification code comes from. I eventually found it too limiting for SQLite queries and Go codegen, so I ditched it (if I use Postgres I’ll try it again; the support seems much richer and deeper).

    So I have a good SQL-based schema. Now I need to actually spin up a SQLite database and load the data into it. That’s where my import.sql comes in:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    
    .mode csv
    .import tmp/employees.csv people
    .import tmp/investments.csv initiatives
    .import tmp/tags.csv tags
    .import tmp/staff-investments.csv staff_investments
    .import tmp/initiative-tags.csv initiative_tags
    DELETE FROM people WHERE employee_type NOT IN ('employee', 'contractor');
    .mode table
    SELECT 'Initiatives' AS 'Item', COUNT(*) AS 'Count' FROM initiatives
    UNION
    SELECT 'People' AS 'Item', COUNT(*) AS 'Count' FROM people
    UNION
    SELECT 'Tags' AS 'Name', COUNT(*) AS 'Count' FROM tags
    UNION
    SELECT 'Initiative Tags' AS 'Name', COUNT(*) AS 'Count' FROM initiative_tags
    UNION
    SELECT 'Staff investments' AS 'Name', COUNT(*) AS 'Count' FROM staff_investments
    ORDER BY 1;
    
    -- Handle the case where the CEO should not have a manager defined
    UPDATE people set manager = NULL where id = manager;
    

    This is a script for SQLite specifically, run by as follows, right after I create the schema in which to load data:

    1
    2
    
    sqlite3 {{.DB}} < data/schema.sql
    sqlite3 {{.DB}} < data/import.sql
    

    Salient points:

    1. It’s very, very simple to load CSV data into SQLite if the schema and the CSV files match. That’s why I was so careful to have my yq commands spit out CSV files that match my target schema in terms of column order and column name.
    2. I liked using the UNION to get a quick summary of the data I just loaded. That helped me spot easy mistakes like…nothing got imported, or having twice as many employees as expected!
    3. The SQLite pragma for .mode table made that a breeze to see on the console. Check it out:
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    
    $ task db
    task: [db] rm -f db.sqlite3
    task: [tmp-dir] mkdir -p tmp
    task: [tmp-dir] mkdir -p tmp
    task: Task "fetch-data" is up to date
    task: [parse] yq '.[] | [key, .name, .email, .title, .employment_type, .cost_center, .manager] | @csv' tmp/hubbers.yml > tmp/employees.csv
    task: [parse] yq '.[] | [key, .name, .link, .description] | @csv' investments.yml > tmp/investments.csv
    task: [parse] yq '.[].investments[] | [parent() | parent() | key,keys,.[]] | flatten | @csv' investments.yml > tmp/staff-investments.csv
    task: [parse] yq '.[].tags[]' investments.yml | uniq > tmp/tags.csv
    task: [parse] yq e '.[].tags[] | [parent() | parent() | key,.] | @csv' investments.yml > tmp/initiative-tags.csv
    task: [db] sqlite3 db.sqlite3 < data/schema.sql
    task: [db] sqlite3 db.sqlite3 < data/import.sql
    +-------------------+-------+
    |       Item        | Count |
    +-------------------+-------+
    | Initiative Tags   | 4     |
    | Initiatives       | 4     |
    | People            | XXXX  |
    | Staff investments | 17    |
    | Tags              | 3     |
    +-------------------+-------+
    

    Woohoo! I’ve got my YAML all CSV-ified and into SQLite where I can start querying it.

    The Common Table Expressions (CTE)

    Finally! One of the prime motivators for doing this work in the first place was to learn more about hierarchical data in SQL, and how to use CTEs to simplify more complex logic into simpler individual steps. And even more: combine the two into recursive CTEs. Woohoo!

    This is going to be an eye-chart. I created three views that I want to use to answer the questions I posed at the beginning of this series. First, I want to get a seamless view of employees, with their hierarchy, and the total number of people on that persons entire organizational team; not just the people they directly manage, but everyone under them in the org chart.

    The really interesting pattern I found with CTEs in general, was the ability to decompose overly complex logic into simpler steps, and then combine them back together into a single view. It’s not like I discovered that! Decomposition appears to be one of the fundamental benefits of CTEs: make it easy to reason about. While writing these CTEs it was also really nice to get little chunks of logic working first and then string them together for the big result.

    The organizing has a hierarchy

    OK, so look at the first highlight. This is a recursive CTE. You can see that from the WITH opening statement and the immediate RECURSIVE keyword. For recursive CTEs the second notable thing is the UNION statement.

    The first part of the query before the UNION tells us where we’ll start our hierarchical walk. In my case, WHERE id = (select id from people where manager IS NULL) on line 24 tells us to start at the CEO: the person who has no manager.

    The second part of the query after the UNION tells us how to walk the hierarchy from that starting point…recursively. I do that by joining the row(s) in the second section to the parent manager from the first section: JOIN subordinate s ON p.manager = s.id.

    As a side note, the json_array() function is a really nice way to build up a JSON array in SQLite. I wanted this because it made my eventual JSON output easier to work with. For example, if Bob reports to Alice, and Alice reports to John, and John reports to Janet who is the CEO, then the value of the org_tree column for Bob would be ["Janet", "John", "Alice"]. A JSON array of strings.

    But it keeps going. Look at line 40 – notice the , comma indicating that our next CTE is coming in hot on the heels of the first one. And sure enough, the team_size CTE begins on line 57. This is mostly the same but the recursion is used to add up the total number of people in any single employee’s org hierarchy: SELECT COUNT(*) FROM cte WHERE manager IS NOT NULL. So now I have, for my imaginary Bob, a column called org_count containing a recursive count of employees reporting to Bob…or just zero if Bob is what we call ‘an individual contributor’ (no one reports to them).

    Finally, on line 85 we have a much simpler LEFT JOIN that pulls the information from the subordinate CTE and team_size CTE into a single view. Literally a view since back up on line 5 I opened with CREATE VIEW IF NOT EXISTS org.

      1
      2
      3
      4
      5
      6
      7
      8
      9
     10
     11
     12
     13
     14
     15
     16
     17
     18
     19
     20
     21
     22
     23
     24
     25
     26
     27
     28
     29
     30
     31
     32
     33
     34
     35
     36
     37
     38
     39
     40
     41
     42
     43
     44
     45
     46
     47
     48
     49
     50
     51
     52
     53
     54
     55
     56
     57
     58
     59
     60
     61
     62
     63
     64
     65
     66
     67
     68
     69
     70
     71
     72
     73
     74
     75
     76
     77
     78
     79
     80
     81
     82
     83
     84
     85
     86
     87
     88
     89
     90
     91
     92
     93
     94
     95
     96
     97
     98
     99
    100
    101
    102
    103
    
    /* Pull together all data on people and add information about each person's team size (size = 1 for people with a team reporting to them). And add a zero-based org_level showing how far down the org hierarchy they are, where zero is the CEO (manager = NULL).
      It does the work in three successive common table expressions
      (CTEs) to make it easier to see how the parts come together.
    */
    CREATE VIEW IF NOT EXISTS org
    AS
    /*
      CTE 1 - Org Levels
    
      Build the employee hierarchy to get a level column useful
      for aggregating data.
    */
    WITH RECURSIVE subordinate AS (
        /*
          Get the root note for our hierarch.
          This where clause is only true for one person: the CEO.
        */
        SELECT  *,
                0 AS org_level,
                -- An empty JSON array
                -- https://www.sqlite.org/json1.html#jset
                json_array() as org_tree
        FROM people
        WHERE id = (select id from people where manager IS NULL)
     
        UNION ALL
     
        /*
          Now get the same info, but note that we're joining
          to the subordinate table recursively to walk the hierarchy.
        */
        SELECT  p.*,
                org_level + 1,
                -- Add the person's manager to the end of the JSON array
                -- JSON arrays preserve order
                -- https://www.sqlite.org/json1.html#jins
                json_insert(org_tree, '$[#]', p.manager)
        FROM people p
        JOIN subordinate s ON p.manager = s.id
      ),
    
    /*
      CTE 2 - Team size
    
      Get the complete count of people reporting
      up to any single person as `org_count`.
    
      This is hierarchical so:
    
      `SELECT * FROM view_team_size WHERE id = 'ashtom'`
    
      should get all the people reporting to the CEO.
      Querying for someone without any people reporting
      to them should return `org_count` of `1`.
      for aggregating data.
    */
    team_size AS (
      SELECT DISTINCT
        p.id as id,
        (
          WITH RECURSIVE cte AS (
              SELECT 
                  pp.id,
                  pp.manager
              FROM people pp
              WHERE pp.id = p.id
              
              UNION ALL
    
              SELECT
                  pp.id,
                  pp.manager
              FROM people pp
              JOIN cte ON pp.manager = cte.id
          )
          SELECT COUNT(*) FROM cte WHERE manager IS NOT NULL
        ) AS org_count
      FROM people p
    ),
    
    /*
      CTE 3 - Combine people data with the hierarchy
      and team size
    */
    employees as (
        SELECT 
          s.id,
          s.name,
          s.email,
          s.title,
          s.cost_center,
          COALESCE(s.manager, s.id) as manager,
          s.org_level,
          s.org_tree,
          COALESCE(m.name, s.id) AS manager_name,
          ts.org_count AS org_size
        FROM subordinate s
        LEFT JOIN people m
          ON s.manager = m.id
        JOIN team_size ts
          ON s.id = ts.id
    )
    SELECT * FROM employees;

    Let’s look at some data (I’m fudging the data to protect the innocent):

    1
    
    select * from org where id = 'bitsbyd';
    
    idnameemailtitlecost_centermanagerorg_levelorg_treemanager_nameorg_size
    bitsbydDylan Thomasbitsbyd@github.comSenior Director Technical Program ManagementORG NAMEALICE3[“The Boss”,“Another Boss”,“My Boss”]Alice18

    The salient points are the reporting structure as a JSON array, the total org size under me, and my level within the org (which comes in next).

    Investments

    Next up I want to figure out where all these employees are investing their time. The CTE pattern looks exactly the same now: multiple little-ish queries that I can reason about ‘in the small’, and then combine them into a single view.

    I put quite a few inline comments in these queries because I’m baking business rules into SQL. Depending on who you believe, that’s either heresy or just not a big deal. I’m the one writing this code, so it’s not a big deal!!!

    The first point of interest is the use of json_each() to iterate over the org_tree column. That really took me a while to unpack. There’s some good documentation on the SQLite JSON functions but to be honest I think the nuances is how these group/array functions work compared to other SQL-like set-based operations work. At least, it had me turned around a few times.

    The rest of it is pretty straightforward. Sure take your time with it and ready it carefully, but apart from the CASE statement and my usual habit of getting the truthiness of WHEN...THEN wrong, it’s pretty straightforward.

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    
    /*
      Build a single view of investments at the
      staff level for each Initiative.
      
      This enforces a rule: investments from subordinates
      are discarded if someone higher up in the organization has
      an investment for the same initiative. Look at the `included`
      column to see if any one person's investment is included.
    
      Second, investments are shown as 'full time equivalents' or
      FTEs. This is 'the equivalent of one full-time employee.
      For investments that we want to include, just multiple the
      total team size (org size) of the person by the percentage
      investment they are making. For example, if Alice has an
      org size of 100 people, and an investment of 0.5 (50%) then
      the FTE column will have a value of 50 (people).
    */
    CREATE VIEW IF NOT EXISTS investments
    AS
    
    WITH management AS (
      SELECT
        si.initiative,
        org.id AS id,
        json_each.value as management
      FROM org, json_each(org_tree)
      INNER JOIN staff_investments si
      ON org.id = si.person
    ),
    
    management_investment AS (
      /*
        The investment amount for each record should only be used
        in calculations if nobody further up in the org has an
        investment for the same initiative. For example, if Alice has
        an investment of 0.5 for the Project A initiaitve and nobody 
        further up in the org has added an investment for Project A
        then the value for `include_investment` will be TRUE (1).
        However, if Bob is somewhere in Alice's management hierarchy
        (like Bob is her boss's boss) and Bob has recorded any investment
        value for Project A, then Alice's investment will be ignored
        (`include_investment` is FALSE (0).
      */
      SELECT
        mi.initiative,
        mi.id,
        -- Nobody in the management chain who has an investment
        -- for an Initiative? Set this to true.
        (COUNT(m.management) = 0) AS include_investment
      FROM
        management mi
      LEFT JOIN management m
        ON (mi.initiative = m.initiative AND mi.management = m.id)
      GROUP BY mi.initiative, mi.id
    ),
    
    investments AS (
      SELECT
        si.initiative,
        si.person,
        si.investment,
        org.org_size,
        mi.include_investment as included,
        org.org_level,
        CASE 
        -- Stop investments of greater that 100% of any person's total team size
        -- General calc works because `include_investment` is zero for 'no investment'.
        WHEN (org.org_size = 1)
          -- Deal with rounding so that an investment of, say, 0.4 for an individual
          -- person (team size of 1) does not get rounded to zero.
          THEN (1.0 * include_investment)
        ELSE org.org_size * si.investment * mi.include_investment
        END as fte
      FROM staff_investments si
      JOIN management_investment mi
        ON (si.person = mi.id AND si.initiative = mi.initiative)
      JOIN org ON si.person = org.id
    )
    
    SELECT * FROM investments
    ORDER BY initiative ASC, fte DESC, org_level DESC;

    Let’s look at some data:

    1
    
    select * from investments where person = 'bitsbyd' and initiative = 'super-secret';
    

    I have committed 100% of my team of 18 people, which means that I have 18 FTEs invested in this initiative. And it says TRUE (1) in the included column, which means that my investment is included in the total investment for this initiative, because nobody above me in the company has said they’re investing in this initiative.

    initiativepersoninvestmentorg_sizeincludedorg_levelfte
    super-secretbitsbyd1.0181318.0

    The final view is really simple and I’m just including it here for completeness. Basically, fix some empty things and add up the FTEs for each initiative.

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    /*
      Initiatives with total FTE investment
    */
    CREATE VIEW IF NOT EXISTS initiative_view
    AS 
    SELECT
      i.id,
      i.name,
      COALESCE(i.url, "") as url,
      COALESCE(i.description, "") as description,
      (SELECT SUM(fte) FROM investments v WHERE i.id = v.initiative) AS total_investment
    FROM initiatives i;

    And the data shows that my investment and that of others (totally fabricated) is included in the total investment for this initiative.

    1
    
    select * from initiative_view where id = 'super-secret';
    
    idnameurldescriptiontotal_investment
    super-secretSuper Secrethttps://www.github.comSomething neat goes here.24.4

    Using in a Go program

    I’ll stop here and keep focused on the SQL bits in this post. But as a bit of a teaser, I’m going to show how I’m using this data in a Go program. I decided to use GORM to access the data. As an ORM most of the work was done using object-like interfaces, like getting a person using their handle. But I’ve also included an example where dropping straight into some SQL is useful.

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    
    import (
    	"log"
    
    	"gorm.io/driver/sqlite"
    	"gorm.io/gorm"
    	"gorm.io/gorm/clause"
    	"gorm.io/gorm/logger"
    )
    
    func (r *Repository) GetPersonByHandle(handle string) (*Person, error) {
    	var person Person
    	if r.db.Where(&Person{ID: handle}).First(&person); r.db.Error != nil {
    		log.Printf("Error: %s", r.db.Error)
    		return nil, r.db.Error
    	}
    	return &person, nil
    }
    
    func (r *Repository) InvestmentByCostCenter(initiative string) ([]CostCenterInvestment, error) {
    	sql := `SELECT
    			i.initiative as Initiative,
    			o.cost_center as CostCenter,
    			SUM(i.fte) as Invested
    		FROM investments i JOIN org o on i.person = o.id
    		WHERE i.initiative = ?
    		GROUP BY o.cost_center, i.initiative
    		HAVING Invested > 0
    		ORDER BY Initiative, CostCenter`
    	var result []CostCenterInvestment
    	r.db.Raw(sql, initiative).Scan(&result)
    
    	if r.db.Error != nil {
    		log.Printf("Error: %s", r.db.Error)
    		return nil, r.db.Error
    	}
    	return result, nil
    }
    

    That’s it for now. Next up, using this stuff in a Go web app, and some GORM-isms that I learned along the way.


    See Also

    This article is part of a series.

      🔗 Share on LinkedIn or visit me on Mastodon